feat: add admin panel

This commit is contained in:
2026-06-15 20:54:05 +03:30
parent 7dadb23fbe
commit 02060a4dc0
16 changed files with 698 additions and 108 deletions
+1 -97
View File
@@ -52,7 +52,7 @@ type TableTier struct {
Trophy int `json:"trophy"`
}
// Catalog کل کاتالوگ فروشگاه (برای GET /api/shop).
// Catalog کل کاتالوگ فروشگاه (برای GET /api/shop). از دیتابیس بارگذاری می‌شود.
type Catalog struct {
CoinPackages []CoinPackage `json:"coin_packages"`
TicketPackages []TicketPackage `json:"ticket_packages"`
@@ -61,102 +61,6 @@ type Catalog struct {
TableTiers []TableTier `json:"table_tiers"`
}
// coinPackages — مطابق فروشگاه مرجع.
var coinPackages = []CoinPackage{
{ID: "small", Title: "بسته کوچک", Coins: 6000, VIPDays: 0, PriceToman: 14950, BonusPct: 0},
{ID: "medium", Title: "بسته متوسط", Coins: 20000, VIPDays: 3, PriceToman: 37900, BonusPct: 20},
{ID: "large", Title: "بسته بزرگ", Coins: 80000, VIPDays: 7, PriceToman: 134900, BonusPct: 30},
{ID: "xlarge", Title: "بسته خیلی بزرگ", Coins: 180000, VIPDays: 15, PriceToman: 279900, BonusPct: 40},
{ID: "luxury", Title: "بسته لاکچری", Coins: 280000, VIPDays: 20, PriceToman: 379900, BonusPct: 50},
{ID: "sultans", Title: "بسته سلاطین", Coins: 700000, VIPDays: 30, PriceToman: 999000, BonusPct: 60},
}
var ticketPackages = []TicketPackage{
{ID: "small", Title: "بسته کوچک", Tickets: 10, PriceToman: 6900},
{ID: "medium", Title: "بسته متوسط", Tickets: 50, PriceToman: 24900},
{ID: "large", Title: "بسته بزرگ", Tickets: 150, PriceToman: 64900},
{ID: "xlarge", Title: "بسته خیلی بزرگ", Tickets: 500, PriceToman: 199900},
}
var cardSkins = []CardSkin{
{ID: "simple", Title: "بسته کارت ساده", PriceCoins: 0},
{ID: "swiss", Title: "بسته کارت سوئیسی", PriceCoins: 6000},
{ID: "wizard", Title: "بسته کارت جادوگر", PriceCoins: 10000},
{ID: "western", Title: "بسته کارت وسترن", PriceCoins: 12000},
{ID: "french", Title: "بسته کارت فرانسوی", PriceCoins: 20000},
{ID: "epic", Title: "بسته کارت حماسی", PriceCoins: 25000},
}
var boosters = []Booster{
{ID: "xp2", Title: "بسته تجربه دو برابر", Multiplier: 2, Hours: 12, PriceToman: 6900},
{ID: "xp3", Title: "بسته تجربه سه برابر", Multiplier: 3, Hours: 24, PriceToman: 19900},
}
// tableTiers — انواع میز (ورودی/جایزه/XP/جام).
var tableTiers = []TableTier{
{ID: "beginner", Title: "مبتدی", Hands: 3, Entry: 100, Prize: 250, XP: 15, Trophy: 50},
{ID: "amateur", Title: "پیوست", Hands: 3, Entry: 2500, Prize: 5000, XP: 25, Trophy: 100},
{ID: "pro", Title: "حرفه‌ای", Hands: 7, Entry: 2500, Prize: 5000, XP: 25, Trophy: 100},
{ID: "master", Title: "استادان", Hands: 7, Entry: 20000, Prize: 40000, XP: 40, Trophy: 200},
}
// FullCatalog کل کاتالوگ را برمی‌گرداند.
func FullCatalog() Catalog {
return Catalog{
CoinPackages: coinPackages,
TicketPackages: ticketPackages,
CardSkins: cardSkins,
Boosters: boosters,
TableTiers: tableTiers,
}
}
func findCoinPackage(id string) *CoinPackage {
for i := range coinPackages {
if coinPackages[i].ID == id {
return &coinPackages[i]
}
}
return nil
}
func findTicketPackage(id string) *TicketPackage {
for i := range ticketPackages {
if ticketPackages[i].ID == id {
return &ticketPackages[i]
}
}
return nil
}
func findCardSkin(id string) *CardSkin {
for i := range cardSkins {
if cardSkins[i].ID == id {
return &cardSkins[i]
}
}
return nil
}
func findBooster(id string) *Booster {
for i := range boosters {
if boosters[i].ID == id {
return &boosters[i]
}
}
return nil
}
// FindTier نوع میز را برمی‌گرداند (برای لایه بازی).
func FindTier(id string) *TableTier {
for i := range tableTiers {
if tableTiers[i].ID == id {
return &tableTiers[i]
}
}
return nil
}
// LevelInfo سطح فعلی، XP درون‌سطح و XP لازم برای سطح بعد را از XP کل محاسبه می‌کند.
// نیاز هر سطح: ۵۰ × شماره سطح (مثلاً سطح ۸ ⇒ ۴۰۰).
func LevelInfo(totalXP int64) (level int, into int64, need int64) {
+157
View File
@@ -0,0 +1,157 @@
package economy
import "context"
// LoadCatalog کاتالوگ را از دیتابیس خوانده و در حافظه کش می‌کند.
// در شروع برنامه و پس از هر ویرایشِ ادمین فراخوانی می‌شود.
func (s *Service) LoadCatalog(ctx context.Context) error {
var c Catalog
rows, err := s.db.QueryContext(ctx,
`SELECT id, title, coins, vip_days, price_toman, bonus_pct FROM coin_packages WHERE enabled = 1 ORDER BY sort`)
if err != nil {
return err
}
for rows.Next() {
var p CoinPackage
if err := rows.Scan(&p.ID, &p.Title, &p.Coins, &p.VIPDays, &p.PriceToman, &p.BonusPct); err != nil {
rows.Close()
return err
}
c.CoinPackages = append(c.CoinPackages, p)
}
rows.Close()
rows, err = s.db.QueryContext(ctx,
`SELECT id, title, tickets, price_toman FROM ticket_packages WHERE enabled = 1 ORDER BY sort`)
if err != nil {
return err
}
for rows.Next() {
var p TicketPackage
if err := rows.Scan(&p.ID, &p.Title, &p.Tickets, &p.PriceToman); err != nil {
rows.Close()
return err
}
c.TicketPackages = append(c.TicketPackages, p)
}
rows.Close()
rows, err = s.db.QueryContext(ctx,
`SELECT id, title, price_coins FROM card_skins WHERE enabled = 1 ORDER BY sort`)
if err != nil {
return err
}
for rows.Next() {
var p CardSkin
if err := rows.Scan(&p.ID, &p.Title, &p.PriceCoins); err != nil {
rows.Close()
return err
}
c.CardSkins = append(c.CardSkins, p)
}
rows.Close()
rows, err = s.db.QueryContext(ctx,
`SELECT id, title, multiplier, hours, price_toman FROM boosters WHERE enabled = 1 ORDER BY sort`)
if err != nil {
return err
}
for rows.Next() {
var p Booster
if err := rows.Scan(&p.ID, &p.Title, &p.Multiplier, &p.Hours, &p.PriceToman); err != nil {
rows.Close()
return err
}
c.Boosters = append(c.Boosters, p)
}
rows.Close()
rows, err = s.db.QueryContext(ctx,
`SELECT id, title, hands, entry, prize, xp, trophy FROM table_tiers WHERE enabled = 1 ORDER BY sort`)
if err != nil {
return err
}
for rows.Next() {
var p TableTier
if err := rows.Scan(&p.ID, &p.Title, &p.Hands, &p.Entry, &p.Prize, &p.XP, &p.Trophy); err != nil {
rows.Close()
return err
}
c.TableTiers = append(c.TableTiers, p)
}
rows.Close()
s.mu.Lock()
s.cat = c
s.mu.Unlock()
return nil
}
// Catalog کپیِ کشِ فعلیِ کاتالوگ را برمی‌گرداند.
func (s *Service) Catalog() Catalog {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cat
}
func (s *Service) findCoinPackage(id string) *CoinPackage {
s.mu.RLock()
defer s.mu.RUnlock()
for i := range s.cat.CoinPackages {
if s.cat.CoinPackages[i].ID == id {
p := s.cat.CoinPackages[i]
return &p
}
}
return nil
}
func (s *Service) findTicketPackage(id string) *TicketPackage {
s.mu.RLock()
defer s.mu.RUnlock()
for i := range s.cat.TicketPackages {
if s.cat.TicketPackages[i].ID == id {
p := s.cat.TicketPackages[i]
return &p
}
}
return nil
}
func (s *Service) findCardSkin(id string) *CardSkin {
s.mu.RLock()
defer s.mu.RUnlock()
for i := range s.cat.CardSkins {
if s.cat.CardSkins[i].ID == id {
p := s.cat.CardSkins[i]
return &p
}
}
return nil
}
func (s *Service) findBooster(id string) *Booster {
s.mu.RLock()
defer s.mu.RUnlock()
for i := range s.cat.Boosters {
if s.cat.Boosters[i].ID == id {
p := s.cat.Boosters[i]
return &p
}
}
return nil
}
// FindTier نوع میز را از کش برمی‌گرداند (برای لایه بازی).
func (s *Service) FindTier(id string) *TableTier {
s.mu.RLock()
defer s.mu.RUnlock()
for i := range s.cat.TableTiers {
if s.cat.TableTiers[i].ID == id {
t := s.cat.TableTiers[i]
return &t
}
}
return nil
}
+9 -5
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"strings"
"sync"
"time"
)
@@ -34,6 +35,9 @@ type Service struct {
db *sql.DB
ad AdVerifier
iap IAPVerifier
mu sync.RWMutex // محافظ کشِ کاتالوگ
cat Catalog // کشِ کاتالوگ خوانده‌شده از DB
}
func New(db *sql.DB, ad AdVerifier, iap IAPVerifier) *Service {
@@ -154,7 +158,7 @@ func (s *Service) ClaimAdReward(ctx context.Context, userID int64, token string)
// BuyCard اسکین کارت را با سکه باز می‌کند.
func (s *Service) BuyCard(ctx context.Context, userID int64, cardID string) error {
skin := findCardSkin(cardID)
skin := s.findCardSkin(cardID)
if skin == nil {
return ErrNotFound
}
@@ -185,7 +189,7 @@ func (s *Service) BuyCard(ctx context.Context, userID int64, cardID string) erro
// SelectCard کارت انتخابی کاربر را تنظیم می‌کند (باید مالکش باشد یا کارت پیش‌فرض).
func (s *Service) SelectCard(ctx context.Context, userID int64, cardID string) error {
if findCardSkin(cardID) == nil {
if s.findCardSkin(cardID) == nil {
return ErrNotFound
}
if cardID != "simple" {
@@ -255,7 +259,7 @@ func (s *Service) VerifyPurchase(ctx context.Context, userID int64, req Purchase
var booster *Booster
switch req.Kind {
case "coin":
p := findCoinPackage(req.ProductID)
p := s.findCoinPackage(req.ProductID)
if p == nil {
return ErrNotFound
}
@@ -264,13 +268,13 @@ func (s *Service) VerifyPurchase(ctx context.Context, userID int64, req Purchase
coins += coins / 10 // پاداش ۱۰٪ VIP
}
case "ticket":
p := findTicketPackage(req.ProductID)
p := s.findTicketPackage(req.ProductID)
if p == nil {
return ErrNotFound
}
tickets = p.Tickets
case "booster":
booster = findBooster(req.ProductID)
booster = s.findBooster(req.ProductID)
if booster == nil {
return ErrNotFound
}
+4 -1
View File
@@ -23,6 +23,9 @@ func setup(t *testing.T) (*Service, int64) {
}
id, _ := res.LastInsertId()
svc := New(st.DB, DevAdVerifier{}, DevIAPVerifier{})
if err := svc.LoadCatalog(context.Background()); err != nil {
t.Fatalf("load catalog: %v", err)
}
return svc, id
}
@@ -156,7 +159,7 @@ func TestPurchaseCreditsAndIdempotent(t *testing.T) {
if err := svc.VerifyPurchase(ctx, id, req); err != nil {
t.Fatalf("purchase: %v", err)
}
pkg := findCoinPackage("small")
pkg := svc.findCoinPackage("small")
if c := coinsOf(t, svc, id); c != pkg.Coins {
t.Fatalf("want %d coins, got %d", pkg.Coins, c)
}
+1 -1
View File
@@ -51,7 +51,7 @@ func (h *Handler) Shop(w http.ResponseWriter, r *http.Request) {
selected = p.SelectedCard
}
httpx.JSON(w, http.StatusOK, map[string]any{
"catalog": FullCatalog(),
"catalog": h.svc.Catalog(),
"owned_cards": owned,
"selected_card": selected,
})