From 02060a4dc02ea81bb41d3067b43f3ab1276f15f7 Mon Sep 17 00:00:00 2001 From: Amirmahdi Nourkazemi Date: Mon, 15 Jun 2026 20:54:05 +0330 Subject: [PATCH] feat: add admin panel --- .env.example | 4 + README.md | 13 ++ cmd/server/main.go | 8 + cmd/server/settler.go | 8 +- internal/admin/admin.go | 221 ++++++++++++++++++++++ internal/admin/templates/dashboard.html | 16 ++ internal/admin/templates/layout.html | 38 ++++ internal/admin/templates/shop.html | 96 ++++++++++ internal/admin/templates/users.html | 38 ++++ internal/config/config.go | 4 + internal/economy/catalog.go | 98 +--------- internal/economy/catalog_store.go | 157 +++++++++++++++ internal/economy/economy.go | 14 +- internal/economy/economy_test.go | 5 +- internal/economy/handler.go | 2 +- internal/store/migrations/003_catalog.sql | 84 ++++++++ 16 files changed, 698 insertions(+), 108 deletions(-) create mode 100644 internal/admin/admin.go create mode 100644 internal/admin/templates/dashboard.html create mode 100644 internal/admin/templates/layout.html create mode 100644 internal/admin/templates/shop.html create mode 100644 internal/admin/templates/users.html create mode 100644 internal/economy/catalog_store.go create mode 100644 internal/store/migrations/003_catalog.sql diff --git a/.env.example b/.env.example index 7777574..0bedc35 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,7 @@ KAVE_TEMPLATE=loginotp # ادمین برای تست بدون SMS (اختیاری) ADMIN_MOBILE= ADMIN_OTP= + +# پنل ادمین (HTML در مسیر /admin) — حتماً در پروداکشن عوض شود +ADMIN_PANEL_USER=admin +ADMIN_PANEL_PASS=admin diff --git a/README.md b/README.md index a005b43..ffcce9f 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,19 @@ POST /api/rewards/ad { token } سکه رایگان پس ### تسویه‌ی داخل بازی `join_queue` یک `tier` می‌گیرد (مثل `beginner`/`pro`). هاب هنگام ورود به صف **ورودی** را کسر می‌کند؛ در پایان بازی **جایزه/XP/جام** به برندگانِ انسان واریز و بازی در `game_history` ثبت می‌شود. اگر بازی پیش از پایان لغو شود، ورودی **بازگردانده** می‌شود (به‌جز کسی که داوطلبانه `leave` کرده). تسویه پشت اینترفیس `ws.Settler` است (لایه ws از سکه بی‌خبر می‌ماند). +## دیتابیس + +**SQLite** (درایور خالص Go، بدون cgo) — مناسبِ سرور کوچک (۱GB RAM / ۱ هسته): پروسه‌ی جدا و رمِ اضافه ندارد، حالتِ بازی در حافظه است و نوشتن فقط هنگام تسویه/خرید/پاداش رخ می‌دهد. برای بکاپ روی یک سرور تک، **Litestream** پیشنهاد می‌شود. کد با `database/sql` نوشته شده تا مهاجرت به Postgres در آینده کم‌دردسر باشد. migrationها با جدول `schema_migrations` فقط یک‌بار اجرا می‌شوند. + +## پنل ادمین (HTML سرور‌ساید) + +در مسیر **`/admin`**، داخل همان باینری Go (بدون پروسه/بیلدِ جدا)، پشت **Basic Auth** (`ADMIN_PANEL_USER`/`ADMIN_PANEL_PASS`): +- **داشبورد**: تعداد کاربران، خریدها، بازی‌ها، مجموع سکه. +- **فروشگاه**: ویرایش زنده‌ی کاتالوگ (بسته‌های سکه/بلیط، اسکین کارت، بوستر، انواع میز). با ذخیره، کشِ کاتالوگ تازه می‌شود و **هم API و هم تسویه‌ی بازی فوراً** مقادیر جدید را می‌گیرند. +- **کاربران**: جستجو، مشاهده‌ی موجودی/سطح، و کم/زیادکردن سکه (ثبت در `wallet_tx`). + +کاتالوگ فروشگاه از جدول‌های دیتابیس خوانده می‌شود (مهاجرت `003_catalog.sql` با مقادیر اولیه seed می‌کند) و در حافظه کش می‌شود. + ## ساخت باینری / Docker ```bash diff --git a/cmd/server/main.go b/cmd/server/main.go index 1318753..b53a317 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -11,6 +11,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + "hakemsho/internal/admin" "hakemsho/internal/auth" "hakemsho/internal/config" "hakemsho/internal/economy" @@ -41,6 +42,10 @@ func main() { // اقتصاد و فروشگاه. تأییدکننده‌ها فعلاً نسخه‌ی توسعه‌اند؛ آداپتر واقعی // تپسل (تبلیغ) و بازار/مایکت (IAP) با تنظیم creds جایگزین می‌شوند. eco := economy.New(st.DB, economy.DevAdVerifier{}, economy.DevIAPVerifier{}) + if err := eco.LoadCatalog(context.Background()); err != nil { + slog.Error("load catalog", "err", err) + os.Exit(1) + } ecoH := economy.NewHandler(eco) // هاب WebSocket برای بازی realtime @@ -75,6 +80,9 @@ func main() { // WebSocket بازی (احراز هویت با توکن در ?token= یا هدر Authorization) r.Get("/ws", hub.ServeWS) + // پنل ادمین (HTML سرور‌ساید، پشت Basic Auth) + r.Mount("/admin", admin.New(st.DB, eco).Routes(cfg.AdminUser, cfg.AdminPass)) + r.Route("/api", func(r chi.Router) { r.Route("/auth", func(r chi.Router) { r.Post("/login-otp", authH.LoginOTP) diff --git a/cmd/server/settler.go b/cmd/server/settler.go index 579b7c8..ca542fb 100644 --- a/cmd/server/settler.go +++ b/cmd/server/settler.go @@ -14,7 +14,7 @@ type gameSettler struct { } func (g gameSettler) ChargeEntry(userID int64, tier string) error { - t := economy.FindTier(tier) + t := g.eco.FindTier(tier) if t == nil { return nil // tier ناشناخته ⇒ بدون ورودی } @@ -22,7 +22,7 @@ func (g gameSettler) ChargeEntry(userID int64, tier string) error { } func (g gameSettler) Refund(userID int64, tier string) { - t := economy.FindTier(tier) + t := g.eco.FindTier(tier) if t == nil { return } @@ -32,7 +32,7 @@ func (g gameSettler) Refund(userID int64, tier string) { } func (g gameSettler) AwardWinner(userID int64, tier string) { - t := economy.FindTier(tier) + t := g.eco.FindTier(tier) if t == nil { return } @@ -49,7 +49,7 @@ func (g gameSettler) RecordGame(room, playersJSON string, winnerTeam int, kot bo // TargetHands تعداد هَندِ لازم برای برد بازی در این tier (مثلاً مبتدی=۳). func (g gameSettler) TargetHands(tier string) int { - t := economy.FindTier(tier) + t := g.eco.FindTier(tier) if t == nil || t.Hands <= 0 { return 7 } diff --git a/internal/admin/admin.go b/internal/admin/admin.go new file mode 100644 index 0000000..834583d --- /dev/null +++ b/internal/admin/admin.go @@ -0,0 +1,221 @@ +// پکیج admin یک پنل ادمینِ سرور‌سایدِ سبک (HTML) برای مدیریت فروشگاه و کاربران است. +// همه‌ی صفحات پشت Basic Auth هستند و در همان باینری Go سرو می‌شوند (بدون پروسه‌ی جدا). +package admin + +import ( + "context" + "database/sql" + "embed" + "html/template" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + + "hakemsho/internal/economy" +) + +//go:embed templates/*.html +var tplFS embed.FS + +type Handler struct { + db *sql.DB + eco *economy.Service + tpl *template.Template +} + +func New(db *sql.DB, eco *economy.Service) *Handler { + tpl := template.Must(template.ParseFS(tplFS, "templates/*.html")) + return &Handler{db: db, eco: eco, tpl: tpl} +} + +// Routes زیرروترِ /admin را با Basic Auth برمی‌گرداند. +func (h *Handler) Routes(user, pass string) http.Handler { + r := chi.NewRouter() + r.Use(middleware.BasicAuth("hakemsho-admin", map[string]string{user: pass})) + r.Get("/", h.dashboard) + r.Get("/shop", h.shop) + r.Post("/shop/{kind}", h.updateItem) + r.Get("/users", h.users) + r.Post("/users/coins", h.adjustCoins) + return r +} + +func (h *Handler) render(w http.ResponseWriter, page string, data any) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := h.tpl.ExecuteTemplate(w, page, data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +// --- داشبورد --- + +func (h *Handler) dashboard(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + count := func(q string) int64 { + var n int64 + _ = h.db.QueryRowContext(ctx, q).Scan(&n) + return n + } + data := map[string]any{ + "Users": count(`SELECT COUNT(*) FROM users`), + "Purchases": count(`SELECT COUNT(*) FROM purchases WHERE status='verified'`), + "Games": count(`SELECT COUNT(*) FROM game_history`), + "Coins": count(`SELECT COALESCE(SUM(coins),0) FROM users`), + "Nav": "dashboard", + } + h.render(w, "dashboard.html", data) +} + +// --- فروشگاه (ویرایش کاتالوگ) --- + +func (h *Handler) shop(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + data := map[string]any{ + "Nav": "shop", + "Coins": h.rows(ctx, `SELECT id,title,coins,vip_days,price_toman,bonus_pct,sort,enabled FROM coin_packages ORDER BY sort`, + "id", "title", "coins", "vip_days", "price_toman", "bonus_pct", "sort", "enabled"), + "Tickets": h.rows(ctx, `SELECT id,title,tickets,price_toman,sort,enabled FROM ticket_packages ORDER BY sort`, + "id", "title", "tickets", "price_toman", "sort", "enabled"), + "Cards": h.rows(ctx, `SELECT id,title,price_coins,sort,enabled FROM card_skins ORDER BY sort`, + "id", "title", "price_coins", "sort", "enabled"), + "Boosters": h.rows(ctx, `SELECT id,title,multiplier,hours,price_toman,sort,enabled FROM boosters ORDER BY sort`, + "id", "title", "multiplier", "hours", "price_toman", "sort", "enabled"), + "Tiers": h.rows(ctx, `SELECT id,title,hands,entry,prize,xp,trophy,sort,enabled FROM table_tiers ORDER BY sort`, + "id", "title", "hands", "entry", "prize", "xp", "trophy", "sort", "enabled"), + "Saved": r.URL.Query().Get("saved") == "1", + } + h.render(w, "shop.html", data) +} + +// updateItem یک ردیفِ کاتالوگ را بر اساس kind به‌روزرسانی و کش را تازه می‌کند. +func (h *Handler) updateItem(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + kind := chi.URLParam(r, "kind") + id := r.FormValue("id") + f := func(k string) int { v, _ := strconv.Atoi(r.FormValue(k)); return v } + en := 0 + if r.FormValue("enabled") == "on" { + en = 1 + } + + var q string + var args []any + switch kind { + case "coin": + q = `UPDATE coin_packages SET title=?,coins=?,vip_days=?,price_toman=?,bonus_pct=?,sort=?,enabled=? WHERE id=?` + args = []any{r.FormValue("title"), f("coins"), f("vip_days"), f("price_toman"), f("bonus_pct"), f("sort"), en, id} + case "ticket": + q = `UPDATE ticket_packages SET title=?,tickets=?,price_toman=?,sort=?,enabled=? WHERE id=?` + args = []any{r.FormValue("title"), f("tickets"), f("price_toman"), f("sort"), en, id} + case "card": + q = `UPDATE card_skins SET title=?,price_coins=?,sort=?,enabled=? WHERE id=?` + args = []any{r.FormValue("title"), f("price_coins"), f("sort"), en, id} + case "booster": + q = `UPDATE boosters SET title=?,multiplier=?,hours=?,price_toman=?,sort=?,enabled=? WHERE id=?` + args = []any{r.FormValue("title"), f("multiplier"), f("hours"), f("price_toman"), f("sort"), en, id} + case "tier": + q = `UPDATE table_tiers SET title=?,hands=?,entry=?,prize=?,xp=?,trophy=?,sort=?,enabled=? WHERE id=?` + args = []any{r.FormValue("title"), f("hands"), f("entry"), f("prize"), f("xp"), f("trophy"), f("sort"), en, id} + default: + http.Error(w, "unknown kind", http.StatusBadRequest) + return + } + + if _, err := h.db.ExecContext(r.Context(), q, args...); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _ = h.eco.LoadCatalog(r.Context()) // تازه‌سازی کشِ کاتالوگ + http.Redirect(w, r, "/admin/shop?saved=1", http.StatusSeeOther) +} + +// --- کاربران --- + +func (h *Handler) users(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + q := r.URL.Query().Get("q") + query := `SELECT id, mobile, coins, tickets, xp, trophies, is_admin FROM users` + var args []any + if q != "" { + query += ` WHERE mobile LIKE ?` + args = append(args, "%"+q+"%") + } + query += ` ORDER BY id DESC LIMIT 100` + + rows, err := h.db.QueryContext(ctx, query, args...) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer rows.Close() + type userRow struct { + ID int64 + Mobile string + Coins, Tickets, XP, Trophy int64 + Level int + IsAdmin bool + } + var list []userRow + for rows.Next() { + var u userRow + var admin int + if err := rows.Scan(&u.ID, &u.Mobile, &u.Coins, &u.Tickets, &u.XP, &u.Trophy, &admin); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + u.IsAdmin = admin == 1 + u.Level, _, _ = economy.LevelInfo(u.XP) + list = append(list, u) + } + h.render(w, "users.html", map[string]any{ + "Nav": "users", + "Users": list, + "Q": q, + "Saved": r.URL.Query().Get("saved") == "1", + }) +} + +// adjustCoins سکه‌ی یک کاربر را افزایش/کاهش می‌دهد (لاگ در wallet_tx). +func (h *Handler) adjustCoins(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + userID, _ := strconv.ParseInt(r.FormValue("user_id"), 10, 64) + amount, _ := strconv.ParseInt(r.FormValue("amount"), 10, 64) + if userID > 0 && amount != 0 { + _ = h.eco.Adjust(r.Context(), userID, economy.CurrencyCoin, amount, "admin_adjust", "") + } + http.Redirect(w, r, "/admin/users?saved=1", http.StatusSeeOther) +} + +// rows یک کوئری را به []map[string]any تبدیل می‌کند (برای رندرِ عمومیِ جدول‌ها). +func (h *Handler) rows(ctx context.Context, query string, cols ...string) []map[string]any { + rs, err := h.db.QueryContext(ctx, query) + if err != nil { + return nil + } + defer rs.Close() + var out []map[string]any + for rs.Next() { + vals := make([]any, len(cols)) + ptrs := make([]any, len(cols)) + for i := range vals { + ptrs[i] = &vals[i] + } + if err := rs.Scan(ptrs...); err != nil { + return out + } + m := make(map[string]any, len(cols)) + for i, c := range cols { + m[c] = vals[i] + } + out = append(out, m) + } + return out +} diff --git a/internal/admin/templates/dashboard.html b/internal/admin/templates/dashboard.html new file mode 100644 index 0000000..f59b8e4 --- /dev/null +++ b/internal/admin/templates/dashboard.html @@ -0,0 +1,16 @@ + + +{{template "head" .}} + +{{template "nav" .}} +
+

داشبورد

+
+
کاربران{{.Users}}
+
خریدهای موفق{{.Purchases}}
+
بازی‌های ثبت‌شده{{.Games}}
+
مجموع سکه‌ی کاربران{{.Coins}}
+
+
+ + diff --git a/internal/admin/templates/layout.html b/internal/admin/templates/layout.html new file mode 100644 index 0000000..b335158 --- /dev/null +++ b/internal/admin/templates/layout.html @@ -0,0 +1,38 @@ +{{define "head"}} + + + + حکم‌شو — پنل ادمین + + +{{end}} + +{{define "nav"}} + +{{end}} diff --git a/internal/admin/templates/shop.html b/internal/admin/templates/shop.html new file mode 100644 index 0000000..436537e --- /dev/null +++ b/internal/admin/templates/shop.html @@ -0,0 +1,96 @@ + + +{{template "head" .}} + +{{template "nav" .}} +
+

فروشگاه

+ {{if .Saved}}
تغییرات ذخیره شد ✓
{{end}} + +

بسته‌های سکه

+ + + {{range .Coins}} + + + + + + + + + + + + {{end}} +
شناسهعنوانسکهVIP (روز)قیمت (تومان)بونوس٪ترتیبفعال
{{.id}}
+ +

بسته‌های بلیط

+ + + {{range .Tickets}} + + + + + + + + + + {{end}} +
شناسهعنوانبلیطقیمت (تومان)ترتیبفعال
{{.id}}
+ +

اسکین کارت‌ها

+ + + {{range .Cards}} + + + + + + + + + {{end}} +
شناسهعنوانقیمت (سکه)ترتیبفعال
{{.id}}
+ +

بوسترها

+ + + {{range .Boosters}} + + + + + + + + + + + {{end}} +
شناسهعنوانضریبساعتقیمت (تومان)ترتیبفعال
{{.id}}
+ +

انواع میز

+ + + {{range .Tiers}} + + + + + + + + + + + + + {{end}} +
شناسهعنواندستورودیجایزهXPجامترتیبفعال
{{.id}}
+
+ + diff --git a/internal/admin/templates/users.html b/internal/admin/templates/users.html new file mode 100644 index 0000000..fa4d138 --- /dev/null +++ b/internal/admin/templates/users.html @@ -0,0 +1,38 @@ + + +{{template "head" .}} + +{{template "nav" .}} +
+

کاربران

+ {{if .Saved}}
انجام شد ✓
{{end}} + + + + + + {{range .Users}} + + + + + + + + + + + {{end}} +
#موبایلسکهبلیطسطحجامادمینتغییر سکه (+/-)
{{.ID}}{{.Mobile}}{{.Coins}}{{.Tickets}}{{.Level}}{{.Trophy}}{{if .IsAdmin}}✓{{end}} +
+ + + +
+
+
+ + diff --git a/internal/config/config.go b/internal/config/config.go index 895ab9a..0c89131 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,6 +18,8 @@ type Config struct { OTPTTL time.Duration // اعتبار کد OTP AdminMobile string // شماره ادمین برای تست بدون SMS AdminOTP string // کد ثابت ادمین + AdminUser string // یوزرنیمِ پنل ادمین (Basic Auth) + AdminPass string // پسوردِ پنل ادمین (Basic Auth) } // Load کانفیگ را از env با مقادیر پیش‌فرض معقول می‌خواند. @@ -33,6 +35,8 @@ func Load() Config { OTPTTL: 15 * time.Minute, AdminMobile: env("ADMIN_MOBILE", ""), AdminOTP: env("ADMIN_OTP", ""), + AdminUser: env("ADMIN_PANEL_USER", "admin"), + AdminPass: env("ADMIN_PANEL_PASS", "admin"), } } diff --git a/internal/economy/catalog.go b/internal/economy/catalog.go index 3d75f9f..e6a561e 100644 --- a/internal/economy/catalog.go +++ b/internal/economy/catalog.go @@ -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) { diff --git a/internal/economy/catalog_store.go b/internal/economy/catalog_store.go new file mode 100644 index 0000000..dc1dc4b --- /dev/null +++ b/internal/economy/catalog_store.go @@ -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 +} diff --git a/internal/economy/economy.go b/internal/economy/economy.go index ce36d3b..46ca3c3 100644 --- a/internal/economy/economy.go +++ b/internal/economy/economy.go @@ -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 } diff --git a/internal/economy/economy_test.go b/internal/economy/economy_test.go index d76075e..df1fa61 100644 --- a/internal/economy/economy_test.go +++ b/internal/economy/economy_test.go @@ -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) } diff --git a/internal/economy/handler.go b/internal/economy/handler.go index 8099dd8..2d3fd4e 100644 --- a/internal/economy/handler.go +++ b/internal/economy/handler.go @@ -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, }) diff --git a/internal/store/migrations/003_catalog.sql b/internal/store/migrations/003_catalog.sql new file mode 100644 index 0000000..f16e014 --- /dev/null +++ b/internal/store/migrations/003_catalog.sql @@ -0,0 +1,84 @@ +-- کاتالوگ فروشگاه در دیتابیس (قابل ویرایش توسط ادمین). +-- مقادیر اولیه = همان پیش‌فرض‌های کدِ قبلی. + +CREATE TABLE IF NOT EXISTS coin_packages ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + coins INTEGER NOT NULL, + vip_days INTEGER NOT NULL DEFAULT 0, + price_toman INTEGER NOT NULL, + bonus_pct INTEGER NOT NULL DEFAULT 0, + sort INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1 +); + +CREATE TABLE IF NOT EXISTS ticket_packages ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + tickets INTEGER NOT NULL, + price_toman INTEGER NOT NULL, + sort INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1 +); + +CREATE TABLE IF NOT EXISTS card_skins ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + price_coins INTEGER NOT NULL DEFAULT 0, + sort INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1 +); + +CREATE TABLE IF NOT EXISTS boosters ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + multiplier INTEGER NOT NULL, + hours INTEGER NOT NULL, + price_toman INTEGER NOT NULL, + sort INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1 +); + +CREATE TABLE IF NOT EXISTS table_tiers ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + hands INTEGER NOT NULL, + entry INTEGER NOT NULL, + prize INTEGER NOT NULL, + xp INTEGER NOT NULL, + trophy INTEGER NOT NULL, + sort INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1 +); + +INSERT OR IGNORE INTO coin_packages (id, title, coins, vip_days, price_toman, bonus_pct, sort) VALUES + ('small', 'بسته کوچک', 6000, 0, 14950, 0, 1), + ('medium', 'بسته متوسط', 20000, 3, 37900, 20, 2), + ('large', 'بسته بزرگ', 80000, 7, 134900, 30, 3), + ('xlarge', 'بسته خیلی بزرگ', 180000, 15, 279900, 40, 4), + ('luxury', 'بسته لاکچری', 280000, 20, 379900, 50, 5), + ('sultans', 'بسته سلاطین', 700000, 30, 999000, 60, 6); + +INSERT OR IGNORE INTO ticket_packages (id, title, tickets, price_toman, sort) VALUES + ('small', 'بسته کوچک', 10, 6900, 1), + ('medium', 'بسته متوسط', 50, 24900, 2), + ('large', 'بسته بزرگ', 150, 64900, 3), + ('xlarge', 'بسته خیلی بزرگ', 500, 199900, 4); + +INSERT OR IGNORE INTO card_skins (id, title, price_coins, sort) VALUES + ('simple', 'بسته کارت ساده', 0, 1), + ('swiss', 'بسته کارت سوئیسی', 6000, 2), + ('wizard', 'بسته کارت جادوگر', 10000, 3), + ('western', 'بسته کارت وسترن', 12000, 4), + ('french', 'بسته کارت فرانسوی', 20000, 5), + ('epic', 'بسته کارت حماسی', 25000, 6); + +INSERT OR IGNORE INTO boosters (id, title, multiplier, hours, price_toman, sort) VALUES + ('xp2', 'بسته تجربه دو برابر', 2, 12, 6900, 1), + ('xp3', 'بسته تجربه سه برابر', 3, 24, 19900, 2); + +INSERT OR IGNORE INTO table_tiers (id, title, hands, entry, prize, xp, trophy, sort) VALUES + ('beginner', 'مبتدی', 3, 100, 250, 15, 50, 1), + ('amateur', 'پیوست', 3, 2500, 5000, 25, 100, 2), + ('pro', 'حرفه‌ای', 7, 2500, 5000, 25, 100, 3), + ('master', 'استادان', 7, 20000, 40000, 40, 200, 4);