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
+4
View File
@@ -14,3 +14,7 @@ KAVE_TEMPLATE=loginotp
# ادمین برای تست بدون SMS (اختیاری)
ADMIN_MOBILE=
ADMIN_OTP=
# پنل ادمین (HTML در مسیر /admin) — حتماً در پروداکشن عوض شود
ADMIN_PANEL_USER=admin
ADMIN_PANEL_PASS=admin
+13
View File
@@ -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
+8
View File
@@ -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)
+4 -4
View File
@@ -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
}
+221
View File
@@ -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
}
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html dir="rtl" lang="fa">
{{template "head" .}}
<body>
{{template "nav" .}}
<div class="wrap">
<h1>داشبورد</h1>
<div class="stats">
<div class="stat">کاربران<b>{{.Users}}</b></div>
<div class="stat">خریدهای موفق<b>{{.Purchases}}</b></div>
<div class="stat">بازی‌های ثبت‌شده<b>{{.Games}}</b></div>
<div class="stat">مجموع سکه‌ی کاربران<b>{{.Coins}}</b></div>
</div>
</div>
</body>
</html>
+38
View File
@@ -0,0 +1,38 @@
{{define "head"}}
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>حکم‌شو — پنل ادمین</title>
<style>
:root { --bg:#240108; --panel:#3a0a12; --gold:#e9b949; --text:#f5e9d0; --accent:#b81d2a; }
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--text); font-family: Tahoma, sans-serif; }
nav { background:#1a0106; padding:12px 18px; display:flex; gap:16px; border-bottom:2px solid var(--gold); }
nav a { color:#caa; text-decoration:none; font-weight:bold; padding:6px 10px; border-radius:8px; }
nav a.active, nav a:hover { color:var(--gold); background:rgba(233,185,73,.12); }
.wrap { padding:20px; max-width:1100px; margin:0 auto; }
h1 { color:var(--gold); }
h2 { color:var(--gold); border-bottom:1px solid #5a0e1a; padding-bottom:6px; margin-top:28px; }
.stats { display:flex; gap:16px; flex-wrap:wrap; }
.stat { background:var(--panel); border:1px solid #5a0e1a; border-radius:14px; padding:18px 26px; text-align:center; }
.stat b { color:var(--gold); font-size:26px; display:block; margin-top:6px; }
table { width:100%; border-collapse:collapse; background:var(--panel); border-radius:12px; overflow:hidden; }
th, td { padding:8px 10px; text-align:right; border-bottom:1px solid #5a0e1a; font-size:13px; }
th { color:var(--gold); }
input { background:#1a0106; color:var(--text); border:1px solid #6b4; border-radius:6px; padding:5px 7px; width:90px; }
input[type=text] { width:130px; }
button { background:var(--accent); color:var(--text); border:1px solid var(--gold); border-radius:8px; padding:6px 14px; font-weight:bold; cursor:pointer; }
button:hover { filter:brightness(1.15); }
.saved { background:#2e7d32; color:#fff; padding:8px 14px; border-radius:8px; display:inline-block; margin:10px 0; }
.search { margin:12px 0; }
</style>
</head>
{{end}}
{{define "nav"}}
<nav>
<a href="/admin" class="{{if eq .Nav "dashboard"}}active{{end}}">داشبورد</a>
<a href="/admin/shop" class="{{if eq .Nav "shop"}}active{{end}}">فروشگاه</a>
<a href="/admin/users" class="{{if eq .Nav "users"}}active{{end}}">کاربران</a>
</nav>
{{end}}
+96
View File
@@ -0,0 +1,96 @@
<!doctype html>
<html dir="rtl" lang="fa">
{{template "head" .}}
<body>
{{template "nav" .}}
<div class="wrap">
<h1>فروشگاه</h1>
{{if .Saved}}<div class="saved">تغییرات ذخیره شد ✓</div>{{end}}
<h2>بسته‌های سکه</h2>
<table>
<tr><th>شناسه</th><th>عنوان</th><th>سکه</th><th>VIP (روز)</th><th>قیمت (تومان)</th><th>بونوس٪</th><th>ترتیب</th><th>فعال</th><th></th></tr>
{{range .Coins}}
<tr><form method="post" action="/admin/shop/coin">
<td>{{.id}}<input type="hidden" name="id" value="{{.id}}"></td>
<td><input type="text" name="title" value="{{.title}}"></td>
<td><input name="coins" value="{{.coins}}"></td>
<td><input name="vip_days" value="{{.vip_days}}"></td>
<td><input name="price_toman" value="{{.price_toman}}"></td>
<td><input name="bonus_pct" value="{{.bonus_pct}}"></td>
<td><input name="sort" value="{{.sort}}"></td>
<td><input type="checkbox" name="enabled" {{if .enabled}}checked{{end}}></td>
<td><button>ذخیره</button></td>
</form></tr>
{{end}}
</table>
<h2>بسته‌های بلیط</h2>
<table>
<tr><th>شناسه</th><th>عنوان</th><th>بلیط</th><th>قیمت (تومان)</th><th>ترتیب</th><th>فعال</th><th></th></tr>
{{range .Tickets}}
<tr><form method="post" action="/admin/shop/ticket">
<td>{{.id}}<input type="hidden" name="id" value="{{.id}}"></td>
<td><input type="text" name="title" value="{{.title}}"></td>
<td><input name="tickets" value="{{.tickets}}"></td>
<td><input name="price_toman" value="{{.price_toman}}"></td>
<td><input name="sort" value="{{.sort}}"></td>
<td><input type="checkbox" name="enabled" {{if .enabled}}checked{{end}}></td>
<td><button>ذخیره</button></td>
</form></tr>
{{end}}
</table>
<h2>اسکین کارت‌ها</h2>
<table>
<tr><th>شناسه</th><th>عنوان</th><th>قیمت (سکه)</th><th>ترتیب</th><th>فعال</th><th></th></tr>
{{range .Cards}}
<tr><form method="post" action="/admin/shop/card">
<td>{{.id}}<input type="hidden" name="id" value="{{.id}}"></td>
<td><input type="text" name="title" value="{{.title}}"></td>
<td><input name="price_coins" value="{{.price_coins}}"></td>
<td><input name="sort" value="{{.sort}}"></td>
<td><input type="checkbox" name="enabled" {{if .enabled}}checked{{end}}></td>
<td><button>ذخیره</button></td>
</form></tr>
{{end}}
</table>
<h2>بوسترها</h2>
<table>
<tr><th>شناسه</th><th>عنوان</th><th>ضریب</th><th>ساعت</th><th>قیمت (تومان)</th><th>ترتیب</th><th>فعال</th><th></th></tr>
{{range .Boosters}}
<tr><form method="post" action="/admin/shop/booster">
<td>{{.id}}<input type="hidden" name="id" value="{{.id}}"></td>
<td><input type="text" name="title" value="{{.title}}"></td>
<td><input name="multiplier" value="{{.multiplier}}"></td>
<td><input name="hours" value="{{.hours}}"></td>
<td><input name="price_toman" value="{{.price_toman}}"></td>
<td><input name="sort" value="{{.sort}}"></td>
<td><input type="checkbox" name="enabled" {{if .enabled}}checked{{end}}></td>
<td><button>ذخیره</button></td>
</form></tr>
{{end}}
</table>
<h2>انواع میز</h2>
<table>
<tr><th>شناسه</th><th>عنوان</th><th>دست</th><th>ورودی</th><th>جایزه</th><th>XP</th><th>جام</th><th>ترتیب</th><th>فعال</th><th></th></tr>
{{range .Tiers}}
<tr><form method="post" action="/admin/shop/tier">
<td>{{.id}}<input type="hidden" name="id" value="{{.id}}"></td>
<td><input type="text" name="title" value="{{.title}}"></td>
<td><input name="hands" value="{{.hands}}"></td>
<td><input name="entry" value="{{.entry}}"></td>
<td><input name="prize" value="{{.prize}}"></td>
<td><input name="xp" value="{{.xp}}"></td>
<td><input name="trophy" value="{{.trophy}}"></td>
<td><input name="sort" value="{{.sort}}"></td>
<td><input type="checkbox" name="enabled" {{if .enabled}}checked{{end}}></td>
<td><button>ذخیره</button></td>
</form></tr>
{{end}}
</table>
</div>
</body>
</html>
+38
View File
@@ -0,0 +1,38 @@
<!doctype html>
<html dir="rtl" lang="fa">
{{template "head" .}}
<body>
{{template "nav" .}}
<div class="wrap">
<h1>کاربران</h1>
{{if .Saved}}<div class="saved">انجام شد ✓</div>{{end}}
<form class="search" method="get" action="/admin/users">
<input type="text" name="q" value="{{.Q}}" placeholder="جستجوی شماره موبایل">
<button>جستجو</button>
</form>
<table>
<tr><th>#</th><th>موبایل</th><th>سکه</th><th>بلیط</th><th>سطح</th><th>جام</th><th>ادمین</th><th>تغییر سکه (+/-)</th></tr>
{{range .Users}}
<tr>
<td>{{.ID}}</td>
<td>{{.Mobile}}</td>
<td>{{.Coins}}</td>
<td>{{.Tickets}}</td>
<td>{{.Level}}</td>
<td>{{.Trophy}}</td>
<td>{{if .IsAdmin}}✓{{end}}</td>
<td>
<form method="post" action="/admin/users/coins" style="display:flex;gap:6px">
<input type="hidden" name="user_id" value="{{.ID}}">
<input name="amount" placeholder="مثلاً 500 یا -200">
<button>اعمال</button>
</form>
</td>
</tr>
{{end}}
</table>
</div>
</body>
</html>
+4
View File
@@ -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"),
}
}
+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,
})
+84
View File
@@ -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);