feat: add admin panel
This commit is contained in:
@@ -14,3 +14,7 @@ KAVE_TEMPLATE=loginotp
|
|||||||
# ادمین برای تست بدون SMS (اختیاری)
|
# ادمین برای تست بدون SMS (اختیاری)
|
||||||
ADMIN_MOBILE=
|
ADMIN_MOBILE=
|
||||||
ADMIN_OTP=
|
ADMIN_OTP=
|
||||||
|
|
||||||
|
# پنل ادمین (HTML در مسیر /admin) — حتماً در پروداکشن عوض شود
|
||||||
|
ADMIN_PANEL_USER=admin
|
||||||
|
ADMIN_PANEL_PASS=admin
|
||||||
|
|||||||
@@ -109,6 +109,19 @@ POST /api/rewards/ad { token } سکه رایگان پس
|
|||||||
### تسویهی داخل بازی
|
### تسویهی داخل بازی
|
||||||
`join_queue` یک `tier` میگیرد (مثل `beginner`/`pro`). هاب هنگام ورود به صف **ورودی** را کسر میکند؛ در پایان بازی **جایزه/XP/جام** به برندگانِ انسان واریز و بازی در `game_history` ثبت میشود. اگر بازی پیش از پایان لغو شود، ورودی **بازگردانده** میشود (بهجز کسی که داوطلبانه `leave` کرده). تسویه پشت اینترفیس `ws.Settler` است (لایه ws از سکه بیخبر میماند).
|
`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
|
## ساخت باینری / Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
|
"hakemsho/internal/admin"
|
||||||
"hakemsho/internal/auth"
|
"hakemsho/internal/auth"
|
||||||
"hakemsho/internal/config"
|
"hakemsho/internal/config"
|
||||||
"hakemsho/internal/economy"
|
"hakemsho/internal/economy"
|
||||||
@@ -41,6 +42,10 @@ func main() {
|
|||||||
// اقتصاد و فروشگاه. تأییدکنندهها فعلاً نسخهی توسعهاند؛ آداپتر واقعی
|
// اقتصاد و فروشگاه. تأییدکنندهها فعلاً نسخهی توسعهاند؛ آداپتر واقعی
|
||||||
// تپسل (تبلیغ) و بازار/مایکت (IAP) با تنظیم creds جایگزین میشوند.
|
// تپسل (تبلیغ) و بازار/مایکت (IAP) با تنظیم creds جایگزین میشوند.
|
||||||
eco := economy.New(st.DB, economy.DevAdVerifier{}, economy.DevIAPVerifier{})
|
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)
|
ecoH := economy.NewHandler(eco)
|
||||||
|
|
||||||
// هاب WebSocket برای بازی realtime
|
// هاب WebSocket برای بازی realtime
|
||||||
@@ -75,6 +80,9 @@ func main() {
|
|||||||
// WebSocket بازی (احراز هویت با توکن در ?token= یا هدر Authorization)
|
// WebSocket بازی (احراز هویت با توکن در ?token= یا هدر Authorization)
|
||||||
r.Get("/ws", hub.ServeWS)
|
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("/api", func(r chi.Router) {
|
||||||
r.Route("/auth", func(r chi.Router) {
|
r.Route("/auth", func(r chi.Router) {
|
||||||
r.Post("/login-otp", authH.LoginOTP)
|
r.Post("/login-otp", authH.LoginOTP)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ type gameSettler struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (g gameSettler) ChargeEntry(userID int64, tier string) error {
|
func (g gameSettler) ChargeEntry(userID int64, tier string) error {
|
||||||
t := economy.FindTier(tier)
|
t := g.eco.FindTier(tier)
|
||||||
if t == nil {
|
if t == nil {
|
||||||
return nil // tier ناشناخته ⇒ بدون ورودی
|
return nil // tier ناشناخته ⇒ بدون ورودی
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,7 @@ func (g gameSettler) ChargeEntry(userID int64, tier string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (g gameSettler) Refund(userID int64, tier string) {
|
func (g gameSettler) Refund(userID int64, tier string) {
|
||||||
t := economy.FindTier(tier)
|
t := g.eco.FindTier(tier)
|
||||||
if t == nil {
|
if t == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -32,7 +32,7 @@ func (g gameSettler) Refund(userID int64, tier string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (g gameSettler) AwardWinner(userID int64, tier string) {
|
func (g gameSettler) AwardWinner(userID int64, tier string) {
|
||||||
t := economy.FindTier(tier)
|
t := g.eco.FindTier(tier)
|
||||||
if t == nil {
|
if t == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,7 @@ func (g gameSettler) RecordGame(room, playersJSON string, winnerTeam int, kot bo
|
|||||||
|
|
||||||
// TargetHands تعداد هَندِ لازم برای برد بازی در این tier (مثلاً مبتدی=۳).
|
// TargetHands تعداد هَندِ لازم برای برد بازی در این tier (مثلاً مبتدی=۳).
|
||||||
func (g gameSettler) TargetHands(tier string) int {
|
func (g gameSettler) TargetHands(tier string) int {
|
||||||
t := economy.FindTier(tier)
|
t := g.eco.FindTier(tier)
|
||||||
if t == nil || t.Hands <= 0 {
|
if t == nil || t.Hands <= 0 {
|
||||||
return 7
|
return 7
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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}}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -18,6 +18,8 @@ type Config struct {
|
|||||||
OTPTTL time.Duration // اعتبار کد OTP
|
OTPTTL time.Duration // اعتبار کد OTP
|
||||||
AdminMobile string // شماره ادمین برای تست بدون SMS
|
AdminMobile string // شماره ادمین برای تست بدون SMS
|
||||||
AdminOTP string // کد ثابت ادمین
|
AdminOTP string // کد ثابت ادمین
|
||||||
|
AdminUser string // یوزرنیمِ پنل ادمین (Basic Auth)
|
||||||
|
AdminPass string // پسوردِ پنل ادمین (Basic Auth)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load کانفیگ را از env با مقادیر پیشفرض معقول میخواند.
|
// Load کانفیگ را از env با مقادیر پیشفرض معقول میخواند.
|
||||||
@@ -33,6 +35,8 @@ func Load() Config {
|
|||||||
OTPTTL: 15 * time.Minute,
|
OTPTTL: 15 * time.Minute,
|
||||||
AdminMobile: env("ADMIN_MOBILE", ""),
|
AdminMobile: env("ADMIN_MOBILE", ""),
|
||||||
AdminOTP: env("ADMIN_OTP", ""),
|
AdminOTP: env("ADMIN_OTP", ""),
|
||||||
|
AdminUser: env("ADMIN_PANEL_USER", "admin"),
|
||||||
|
AdminPass: env("ADMIN_PANEL_PASS", "admin"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ type TableTier struct {
|
|||||||
Trophy int `json:"trophy"`
|
Trophy int `json:"trophy"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Catalog کل کاتالوگ فروشگاه (برای GET /api/shop).
|
// Catalog کل کاتالوگ فروشگاه (برای GET /api/shop). از دیتابیس بارگذاری میشود.
|
||||||
type Catalog struct {
|
type Catalog struct {
|
||||||
CoinPackages []CoinPackage `json:"coin_packages"`
|
CoinPackages []CoinPackage `json:"coin_packages"`
|
||||||
TicketPackages []TicketPackage `json:"ticket_packages"`
|
TicketPackages []TicketPackage `json:"ticket_packages"`
|
||||||
@@ -61,102 +61,6 @@ type Catalog struct {
|
|||||||
TableTiers []TableTier `json:"table_tiers"`
|
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 کل محاسبه میکند.
|
// LevelInfo سطح فعلی، XP درونسطح و XP لازم برای سطح بعد را از XP کل محاسبه میکند.
|
||||||
// نیاز هر سطح: ۵۰ × شماره سطح (مثلاً سطح ۸ ⇒ ۴۰۰).
|
// نیاز هر سطح: ۵۰ × شماره سطح (مثلاً سطح ۸ ⇒ ۴۰۰).
|
||||||
func LevelInfo(totalXP int64) (level int, into int64, need int64) {
|
func LevelInfo(totalXP int64) (level int, into int64, need int64) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,6 +35,9 @@ type Service struct {
|
|||||||
db *sql.DB
|
db *sql.DB
|
||||||
ad AdVerifier
|
ad AdVerifier
|
||||||
iap IAPVerifier
|
iap IAPVerifier
|
||||||
|
|
||||||
|
mu sync.RWMutex // محافظ کشِ کاتالوگ
|
||||||
|
cat Catalog // کشِ کاتالوگ خواندهشده از DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(db *sql.DB, ad AdVerifier, iap IAPVerifier) *Service {
|
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 اسکین کارت را با سکه باز میکند.
|
// BuyCard اسکین کارت را با سکه باز میکند.
|
||||||
func (s *Service) BuyCard(ctx context.Context, userID int64, cardID string) error {
|
func (s *Service) BuyCard(ctx context.Context, userID int64, cardID string) error {
|
||||||
skin := findCardSkin(cardID)
|
skin := s.findCardSkin(cardID)
|
||||||
if skin == nil {
|
if skin == nil {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -185,7 +189,7 @@ func (s *Service) BuyCard(ctx context.Context, userID int64, cardID string) erro
|
|||||||
|
|
||||||
// SelectCard کارت انتخابی کاربر را تنظیم میکند (باید مالکش باشد یا کارت پیشفرض).
|
// SelectCard کارت انتخابی کاربر را تنظیم میکند (باید مالکش باشد یا کارت پیشفرض).
|
||||||
func (s *Service) SelectCard(ctx context.Context, userID int64, cardID string) error {
|
func (s *Service) SelectCard(ctx context.Context, userID int64, cardID string) error {
|
||||||
if findCardSkin(cardID) == nil {
|
if s.findCardSkin(cardID) == nil {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
if cardID != "simple" {
|
if cardID != "simple" {
|
||||||
@@ -255,7 +259,7 @@ func (s *Service) VerifyPurchase(ctx context.Context, userID int64, req Purchase
|
|||||||
var booster *Booster
|
var booster *Booster
|
||||||
switch req.Kind {
|
switch req.Kind {
|
||||||
case "coin":
|
case "coin":
|
||||||
p := findCoinPackage(req.ProductID)
|
p := s.findCoinPackage(req.ProductID)
|
||||||
if p == nil {
|
if p == nil {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -264,13 +268,13 @@ func (s *Service) VerifyPurchase(ctx context.Context, userID int64, req Purchase
|
|||||||
coins += coins / 10 // پاداش ۱۰٪ VIP
|
coins += coins / 10 // پاداش ۱۰٪ VIP
|
||||||
}
|
}
|
||||||
case "ticket":
|
case "ticket":
|
||||||
p := findTicketPackage(req.ProductID)
|
p := s.findTicketPackage(req.ProductID)
|
||||||
if p == nil {
|
if p == nil {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
tickets = p.Tickets
|
tickets = p.Tickets
|
||||||
case "booster":
|
case "booster":
|
||||||
booster = findBooster(req.ProductID)
|
booster = s.findBooster(req.ProductID)
|
||||||
if booster == nil {
|
if booster == nil {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ func setup(t *testing.T) (*Service, int64) {
|
|||||||
}
|
}
|
||||||
id, _ := res.LastInsertId()
|
id, _ := res.LastInsertId()
|
||||||
svc := New(st.DB, DevAdVerifier{}, DevIAPVerifier{})
|
svc := New(st.DB, DevAdVerifier{}, DevIAPVerifier{})
|
||||||
|
if err := svc.LoadCatalog(context.Background()); err != nil {
|
||||||
|
t.Fatalf("load catalog: %v", err)
|
||||||
|
}
|
||||||
return svc, id
|
return svc, id
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +159,7 @@ func TestPurchaseCreditsAndIdempotent(t *testing.T) {
|
|||||||
if err := svc.VerifyPurchase(ctx, id, req); err != nil {
|
if err := svc.VerifyPurchase(ctx, id, req); err != nil {
|
||||||
t.Fatalf("purchase: %v", err)
|
t.Fatalf("purchase: %v", err)
|
||||||
}
|
}
|
||||||
pkg := findCoinPackage("small")
|
pkg := svc.findCoinPackage("small")
|
||||||
if c := coinsOf(t, svc, id); c != pkg.Coins {
|
if c := coinsOf(t, svc, id); c != pkg.Coins {
|
||||||
t.Fatalf("want %d coins, got %d", pkg.Coins, c)
|
t.Fatalf("want %d coins, got %d", pkg.Coins, c)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func (h *Handler) Shop(w http.ResponseWriter, r *http.Request) {
|
|||||||
selected = p.SelectedCard
|
selected = p.SelectedCard
|
||||||
}
|
}
|
||||||
httpx.JSON(w, http.StatusOK, map[string]any{
|
httpx.JSON(w, http.StatusOK, map[string]any{
|
||||||
"catalog": FullCatalog(),
|
"catalog": h.svc.Catalog(),
|
||||||
"owned_cards": owned,
|
"owned_cards": owned,
|
||||||
"selected_card": selected,
|
"selected_card": selected,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user