465 lines
17 KiB
Go
465 lines
17 KiB
Go
// پکیج admin یک پنل ادمینِ سرورسایدِ سبک (HTML) برای مدیریت فروشگاه و کاربران است.
|
|
// همهی صفحات پشت Basic Auth هستند و در همان باینری Go سرو میشوند (بدون پروسهی جدا).
|
|
package admin
|
|
|
|
import (
|
|
"archive/zip"
|
|
"context"
|
|
"database/sql"
|
|
"embed"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"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
|
|
cardsDir string // پوشهی دیسکیِ آپلودِ اسکینها (همان مسیری که static از آن سرو میکند)
|
|
}
|
|
|
|
func New(db *sql.DB, eco *economy.Service, cardsDir string) *Handler {
|
|
tpl := template.Must(template.ParseFS(tplFS, "templates/*.html"))
|
|
return &Handler{db: db, eco: eco, tpl: tpl, cardsDir: cardsDir}
|
|
}
|
|
|
|
// 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}/add", h.addItem)
|
|
r.Post("/shop/{kind}/delete", h.deleteItem)
|
|
r.Post("/shop/card/{id}/upload", h.uploadDeck)
|
|
r.Post("/shop/{kind}", h.updateItem)
|
|
r.Get("/users", h.users)
|
|
r.Post("/users/coins", h.adjustCoins)
|
|
r.Post("/users/grant", h.grantToUser)
|
|
r.Post("/season/reset", h.resetSeason)
|
|
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`),
|
|
"Season": h.eco.Season(ctx),
|
|
"Saved": r.URL.Query().Get("saved") == "1",
|
|
"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"),
|
|
"VIP": h.rows(ctx, `SELECT id,title,months,price_toman,sort,enabled FROM vip_packages ORDER BY sort`,
|
|
"id", "title", "months", "price_toman", "sort", "enabled"),
|
|
"Tiers": h.rows(ctx, `SELECT id,title,hands,entry,prize,xp,trophy,rank_reward,sort,enabled FROM table_tiers ORDER BY sort`,
|
|
"id", "title", "hands", "entry", "prize", "xp", "trophy", "rank_reward", "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 "vip":
|
|
q = `UPDATE vip_packages SET title=?,months=?,price_toman=?,sort=?,enabled=? WHERE id=?`
|
|
args = []any{r.FormValue("title"), f("months"), f("price_toman"), f("sort"), en, id}
|
|
case "tier":
|
|
q = `UPDATE table_tiers SET title=?,hands=?,entry=?,prize=?,xp=?,trophy=?,rank_reward=?,sort=?,enabled=? WHERE id=?`
|
|
args = []any{r.FormValue("title"), f("hands"), f("entry"), f("prize"), f("xp"), f("trophy"), f("rank_reward"), 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)
|
|
}
|
|
|
|
// addItem یک ردیفِ جدید به کاتالوگ اضافه میکند (افزودن محصول).
|
|
func (h *Handler) addItem(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")
|
|
if id == "" {
|
|
http.Error(w, "id required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
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 = `INSERT INTO coin_packages (id,title,coins,vip_days,price_toman,bonus_pct,sort,enabled) VALUES (?,?,?,?,?,?,?,?)`
|
|
args = []any{id, r.FormValue("title"), f("coins"), f("vip_days"), f("price_toman"), f("bonus_pct"), f("sort"), en}
|
|
case "ticket":
|
|
q = `INSERT INTO ticket_packages (id,title,tickets,price_toman,sort,enabled) VALUES (?,?,?,?,?,?)`
|
|
args = []any{id, r.FormValue("title"), f("tickets"), f("price_toman"), f("sort"), en}
|
|
case "card":
|
|
q = `INSERT INTO card_skins (id,title,price_coins,sort,enabled) VALUES (?,?,?,?,?)`
|
|
args = []any{id, r.FormValue("title"), f("price_coins"), f("sort"), en}
|
|
case "booster":
|
|
q = `INSERT INTO boosters (id,title,multiplier,hours,price_toman,sort,enabled) VALUES (?,?,?,?,?,?)`
|
|
args = []any{id, r.FormValue("title"), f("multiplier"), f("hours"), f("price_toman"), f("sort"), en}
|
|
case "vip":
|
|
q = `INSERT INTO vip_packages (id,title,months,price_toman,sort,enabled) VALUES (?,?,?,?,?,?)`
|
|
args = []any{id, r.FormValue("title"), f("months"), f("price_toman"), f("sort"), en}
|
|
case "tier":
|
|
q = `INSERT INTO table_tiers (id,title,hands,entry,prize,xp,trophy,rank_reward,sort,enabled) VALUES (?,?,?,?,?,?,?,?,?,?)`
|
|
args = []any{id, r.FormValue("title"), f("hands"), f("entry"), f("prize"), f("xp"), f("trophy"), f("rank_reward"), f("sort"), en}
|
|
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)
|
|
}
|
|
|
|
// validCardFile نامِ کانونیِ خروجی را برای یک ورودیِ zip برمیگرداند (یا ok=false).
|
|
// مجاز: back.(jpg|png) → "back.jpg" و <کد>.png → "<کدِ بزرگ>.png"
|
|
// که <کد> = رتبه (A,2..10,J,Q,K) + خال (S,H,D,C)، مثل AS.png یا 10H.png.
|
|
// خروجی همیشه «کدِ بزرگ + پسوندِ کوچک» است تا با درخواستهای اپ روی فایلسیستمِ
|
|
// حساسبهحروف (لینوکس) دقیقاً مطابقت کند.
|
|
func validCardFile(name string) (string, bool) {
|
|
base := filepath.Base(name)
|
|
low := strings.ToLower(base)
|
|
if low == "back.jpg" || low == "back.png" {
|
|
return "back.jpg", true // اپ پشتِ کارت را با نامِ back.jpg میخواهد
|
|
}
|
|
if !strings.HasSuffix(low, ".png") {
|
|
return "", false
|
|
}
|
|
code := strings.ToUpper(base[:len(base)-len(".png")])
|
|
suits := "SHDC"
|
|
if len(code) < 2 || !strings.Contains(suits, code[len(code)-1:]) {
|
|
return "", false
|
|
}
|
|
ranks := map[string]bool{"A": true, "2": true, "3": true, "4": true, "5": true,
|
|
"6": true, "7": true, "8": true, "9": true, "10": true, "J": true, "Q": true, "K": true}
|
|
if !ranks[code[:len(code)-1]] {
|
|
return "", false
|
|
}
|
|
return code + ".png", true
|
|
}
|
|
|
|
// uploadDeck یک فایلِ zip شاملِ تصاویرِ کارت را برای یک اسکین آپلود میکند و
|
|
// در پوشهی دیسکیِ ماندگار (cardsDir/<id>/) استخراج میکند تا بدونِ کامپایلِ
|
|
// دوباره از همان مسیرِ /cards/<id>/... سرو شود.
|
|
func (h *Handler) uploadDeck(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if id == "" || strings.ContainsAny(id, "/\\.") {
|
|
http.Error(w, "invalid skin id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if h.cardsDir == "" {
|
|
http.Error(w, "CARDS_DIR تنظیم نشده است", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err := r.ParseMultipartForm(64 << 20); err != nil { // حداکثر ۶۴MB
|
|
http.Error(w, "bad upload", http.StatusBadRequest)
|
|
return
|
|
}
|
|
file, _, err := r.FormFile("deck")
|
|
if err != nil {
|
|
http.Error(w, "deck (zip) لازم است", http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer file.Close()
|
|
buf, err := io.ReadAll(io.LimitReader(file, 64<<20))
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
zr, err := zip.NewReader(strings.NewReader(string(buf)), int64(len(buf)))
|
|
if err != nil {
|
|
http.Error(w, "فایل zip معتبر نیست", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
dir := filepath.Join(h.cardsDir, id)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
written := 0
|
|
for _, zf := range zr.File {
|
|
if zf.FileInfo().IsDir() {
|
|
continue
|
|
}
|
|
out, ok := validCardFile(zf.Name)
|
|
if !ok {
|
|
continue // فایلهای نامربوط/ناقص نادیده گرفته میشوند
|
|
}
|
|
if err := extractZipEntry(zf, filepath.Join(dir, out)); err != nil {
|
|
http.Error(w, fmt.Sprintf("استخراج %s: %v", zf.Name, err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
written++
|
|
}
|
|
if written == 0 {
|
|
http.Error(w, "هیچ تصویرِ کارتِ معتبری در zip یافت نشد (مثل AS.png، 10H.png، back.jpg)", http.StatusBadRequest)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/admin/shop?saved=1", http.StatusSeeOther)
|
|
}
|
|
|
|
func extractZipEntry(zf *zip.File, dst string) error {
|
|
rc, err := zf.Open()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rc.Close()
|
|
f, err := os.Create(dst)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
_, err = io.Copy(f, io.LimitReader(rc, 16<<20)) // سقفِ هر فایل ۱۶MB
|
|
return err
|
|
}
|
|
|
|
// deleteItem یک ردیفِ کاتالوگ را حذف میکند.
|
|
func (h *Handler) deleteItem(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "bad form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
tables := map[string]string{
|
|
"coin": "coin_packages", "ticket": "ticket_packages", "card": "card_skins",
|
|
"booster": "boosters", "vip": "vip_packages", "tier": "table_tiers",
|
|
}
|
|
tbl, ok := tables[chi.URLParam(r, "kind")]
|
|
if !ok {
|
|
http.Error(w, "unknown kind", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if _, err := h.db.ExecContext(r.Context(),
|
|
"DELETE FROM "+tbl+" WHERE id = ?", r.FormValue("id")); 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)
|
|
}
|
|
|
|
// resetSeason امتیازِ رتبهی همه را صفر کرده و فصلِ جدید را آغاز میکند.
|
|
func (h *Handler) resetSeason(w http.ResponseWriter, r *http.Request) {
|
|
if err := h.eco.ResetSeason(r.Context()); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/admin/?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)
|
|
}
|
|
|
|
// grantToUser یک محصول (سکه/بلیط/VIP/کارت) را به کاربر اعطا میکند.
|
|
func (h *Handler) grantToUser(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)
|
|
kind := r.FormValue("kind")
|
|
value := r.FormValue("value")
|
|
ctx := r.Context()
|
|
if userID <= 0 {
|
|
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
var err error
|
|
switch kind {
|
|
case "coins":
|
|
amount, _ := strconv.ParseInt(value, 10, 64)
|
|
err = h.eco.Adjust(ctx, userID, economy.CurrencyCoin, amount, "admin_grant", "")
|
|
case "tickets":
|
|
amount, _ := strconv.ParseInt(value, 10, 64)
|
|
err = h.eco.Adjust(ctx, userID, economy.CurrencyTicket, amount, "admin_grant", "")
|
|
case "vip":
|
|
days, _ := strconv.Atoi(value)
|
|
err = h.eco.GrantVIPDays(ctx, userID, days)
|
|
case "card":
|
|
err = h.eco.GrantCard(ctx, userID, value)
|
|
}
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
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
|
|
}
|