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}}
+
+
بستههای سکه
+
+ | شناسه | عنوان | سکه | VIP (روز) | قیمت (تومان) | بونوس٪ | ترتیب | فعال | |
+ {{range .Coins}}
+
+ {{end}}
+
+
+
بستههای بلیط
+
+ | شناسه | عنوان | بلیط | قیمت (تومان) | ترتیب | فعال | |
+ {{range .Tickets}}
+
+ {{end}}
+
+
+
اسکین کارتها
+
+ | شناسه | عنوان | قیمت (سکه) | ترتیب | فعال | |
+ {{range .Cards}}
+
+ {{end}}
+
+
+
بوسترها
+
+ | شناسه | عنوان | ضریب | ساعت | قیمت (تومان) | ترتیب | فعال | |
+ {{range .Boosters}}
+
+ {{end}}
+
+
+
انواع میز
+
+ | شناسه | عنوان | دست | ورودی | جایزه | XP | جام | ترتیب | فعال | |
+ {{range .Tiers}}
+
+ {{end}}
+
+
+
+
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}}
+
+ | {{.ID}} |
+ {{.Mobile}} |
+ {{.Coins}} |
+ {{.Tickets}} |
+ {{.Level}} |
+ {{.Trophy}} |
+ {{if .IsAdmin}}✓{{end}} |
+
+
+ |
+
+ {{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);