package admin import ( "encoding/json" "fmt" "net/http" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "hakemsho/internal/economy" "hakemsho/internal/httpx" ) // APIRoutes زیرروترِ JSON پنلِ ادمینِ Next.js را برمی‌گرداند (پشتِ همان Basic Auth). // سمتِ کلاینت، هدرِ Authorization: Basic را ذخیره و در هر درخواست می‌فرستد. func (h *Handler) APIRoutes() http.Handler { r := chi.NewRouter() r.Get("/me", h.apiMe) r.Get("/dashboard", h.apiDashboard) r.Get("/catalog", h.apiCatalog) r.Post("/catalog/{kind}", h.apiCatalogUpsert) r.Delete("/catalog/{kind}", h.apiCatalogDelete) r.Post("/catalog/card/{id}/upload", h.uploadDeck) // multipart (اشتراکی با HTML) r.Get("/users", h.apiUsers) r.Post("/users/coins", h.apiUserCoins) r.Post("/users/grant", h.apiUserGrant) r.Get("/carpets", h.apiCarpets) r.Post("/carpets", h.apiCarpetUpsert) r.Delete("/carpets", h.apiCarpetDelete) r.Post("/carpets/{id}/upload", h.carpetUpload) // multipart (اشتراکی) r.Get("/frames", h.apiFrames) r.Post("/frames", h.apiFrameUpsert) r.Delete("/frames", h.apiFrameDelete) r.Get("/chat", h.apiChat) r.Post("/chat/pack", h.apiChatPackUpsert) r.Delete("/chat/pack", h.apiChatPackDelete) r.Post("/chat/message", h.apiChatMessageAdd) r.Delete("/chat/message", h.apiChatMessageDelete) r.Get("/tournaments", h.apiTournaments) r.Post("/tournaments", h.apiTournamentAdd) r.Put("/tournaments", h.apiTournamentUpdate) r.Delete("/tournaments", h.apiTournamentDelete) r.Post("/season/reset", h.apiSeasonReset) return r } func (h *Handler) apiMe(w http.ResponseWriter, _ *http.Request) { httpx.JSON(w, http.StatusOK, map[string]any{"ok": true}) } func (h *Handler) apiDashboard(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 } httpx.JSON(w, http.StatusOK, 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`), "tournaments": count(`SELECT COUNT(*) FROM tournaments`), "season": h.eco.Season(ctx), }) } // --- کاتالوگِ فروشگاه --- var catalogTable = map[string]string{ "coin": "coin_packages", "ticket": "ticket_packages", "card": "card_skins", "booster": "boosters", "vip": "vip_packages", "tier": "table_tiers", } // catalogCols ستون‌های قابلِ ویرایشِ هر نوع (به‌جز sku که خودکار است). var catalogCols = map[string][]string{ "coin": {"id", "title", "coins", "vip_days", "price_toman", "bonus_pct", "sort", "enabled"}, "ticket": {"id", "title", "tickets", "price_toman", "sort", "enabled"}, "card": {"id", "title", "price_coins", "sort", "enabled"}, "booster": {"id", "title", "multiplier", "hours", "price_toman", "sort", "enabled"}, "vip": {"id", "title", "months", "price_toman", "sort", "enabled"}, "tier": {"id", "title", "hands", "entry", "prize", "xp", "trophy", "rank_reward", "sort", "enabled"}, } var catalogHasSKU = map[string]bool{"coin": true, "ticket": true, "booster": true, "vip": true} func (h *Handler) apiCatalog(w http.ResponseWriter, r *http.Request) { ctx := r.Context() httpx.JSON(w, http.StatusOK, map[string]any{ "coin": h.rows(ctx, `SELECT id,title,coins,vip_days,price_toman,bonus_pct,sku,sort,enabled FROM coin_packages ORDER BY sort`, "id", "title", "coins", "vip_days", "price_toman", "bonus_pct", "sku", "sort", "enabled"), "ticket": h.rows(ctx, `SELECT id,title,tickets,price_toman,sku,sort,enabled FROM ticket_packages ORDER BY sort`, "id", "title", "tickets", "price_toman", "sku", "sort", "enabled"), "card": h.rows(ctx, `SELECT id,title,price_coins,sort,enabled FROM card_skins ORDER BY sort`, "id", "title", "price_coins", "sort", "enabled"), "booster": h.rows(ctx, `SELECT id,title,multiplier,hours,price_toman,sku,sort,enabled FROM boosters ORDER BY sort`, "id", "title", "multiplier", "hours", "price_toman", "sku", "sort", "enabled"), "vip": h.rows(ctx, `SELECT id,title,months,price_toman,sku,sort,enabled FROM vip_packages ORDER BY sort`, "id", "title", "months", "price_toman", "sku", "sort", "enabled"), "tier": 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"), }) } // apiCatalogUpsert یک آیتم را می‌سازد یا به‌روزرسانی می‌کند (INSERT OR REPLACE). func (h *Handler) apiCatalogUpsert(w http.ResponseWriter, r *http.Request) { kind := chi.URLParam(r, "kind") table, ok := catalogTable[kind] if !ok { httpx.Error(w, http.StatusBadRequest, "unknown kind") return } var body map[string]any if err := json.NewDecoder(r.Body).Decode(&body); err != nil { httpx.Error(w, http.StatusBadRequest, "invalid body") return } id, _ := body["id"].(string) if strings.TrimSpace(id) == "" { httpx.Error(w, http.StatusBadRequest, "id required") return } cols := append([]string{}, catalogCols[kind]...) vals := make([]any, 0, len(cols)+1) for _, c := range cols { vals = append(vals, coerceCol(c, body[c])) } // SKU: در ساخت خودکار تولید و در ویرایش حفظ می‌شود. if catalogHasSKU[kind] { var sku string _ = h.db.QueryRowContext(r.Context(), fmt.Sprintf(`SELECT sku FROM %s WHERE id=?`, table), id).Scan(&sku) if sku == "" { sku = newUUID() } cols = append(cols, "sku") vals = append(vals, sku) } ph := strings.TrimRight(strings.Repeat("?,", len(cols)), ",") q := fmt.Sprintf(`INSERT OR REPLACE INTO %s (%s) VALUES (%s)`, table, strings.Join(cols, ","), ph) if _, err := h.db.ExecContext(r.Context(), q, vals...); err != nil { httpx.Error(w, http.StatusInternalServerError, err.Error()) return } _ = h.eco.LoadCatalog(r.Context()) httpx.JSON(w, http.StatusOK, map[string]any{"ok": true}) } func (h *Handler) apiCatalogDelete(w http.ResponseWriter, r *http.Request) { kind := chi.URLParam(r, "kind") table, ok := catalogTable[kind] if !ok { httpx.Error(w, http.StatusBadRequest, "unknown kind") return } id := r.URL.Query().Get("id") if _, err := h.db.ExecContext(r.Context(), fmt.Sprintf(`DELETE FROM %s WHERE id=?`, table), id); err != nil { httpx.Error(w, http.StatusInternalServerError, err.Error()) return } _ = h.eco.LoadCatalog(r.Context()) httpx.JSON(w, http.StatusOK, map[string]any{"ok": true}) } // --- کاربران --- func (h *Handler) apiUsers(w http.ResponseWriter, r *http.Request) { q := strings.TrimSpace(r.URL.Query().Get("q")) ctx := r.Context() var rows []map[string]any if q == "" { rows = h.rows(ctx, `SELECT id,mobile,first_name,coins,rank_points,is_admin FROM users ORDER BY id DESC LIMIT 50`, "id", "mobile", "first_name", "coins", "rank_points", "is_admin") } else { like := "%" + q + "%" rows = h.rowsArgs(ctx, `SELECT id,mobile,first_name,coins,rank_points,is_admin FROM users WHERE mobile LIKE ? OR first_name LIKE ? OR CAST(id AS TEXT)=? ORDER BY id DESC LIMIT 50`, []any{like, like, q}, "id", "mobile", "first_name", "coins", "rank_points", "is_admin") } httpx.JSON(w, http.StatusOK, map[string]any{"users": rows}) } func (h *Handler) apiUserCoins(w http.ResponseWriter, r *http.Request) { var b struct { UserID int64 `json:"user_id"` Amount int64 `json:"amount"` } if err := json.NewDecoder(r.Body).Decode(&b); err != nil || b.UserID == 0 { httpx.Error(w, http.StatusBadRequest, "invalid body") return } if err := h.eco.Adjust(r.Context(), b.UserID, economy.CurrencyCoin, b.Amount, "admin_adjust", "panel"); err != nil { httpx.Error(w, http.StatusBadRequest, err.Error()) return } httpx.JSON(w, http.StatusOK, map[string]any{"ok": true}) } func (h *Handler) apiUserGrant(w http.ResponseWriter, r *http.Request) { var b struct { UserID int64 `json:"user_id"` Kind string `json:"kind"` // coin | ticket | vip Amount int64 `json:"amount"` VIPDays int `json:"vip_days"` } if err := json.NewDecoder(r.Body).Decode(&b); err != nil || b.UserID == 0 { httpx.Error(w, http.StatusBadRequest, "invalid body") return } ctx := r.Context() var err error switch b.Kind { case "coin": err = h.eco.Adjust(ctx, b.UserID, economy.CurrencyCoin, b.Amount, "admin_grant", "panel") case "ticket": err = h.eco.Adjust(ctx, b.UserID, economy.CurrencyTicket, b.Amount, "admin_grant", "panel") case "vip": _, err = h.db.ExecContext(ctx, `UPDATE users SET vip_until = datetime('now', ?) WHERE id = ?`, fmt.Sprintf("+%d days", b.VIPDays), b.UserID) default: httpx.Error(w, http.StatusBadRequest, "unknown kind") return } if err != nil { httpx.Error(w, http.StatusBadRequest, err.Error()) return } httpx.JSON(w, http.StatusOK, map[string]any{"ok": true}) } // --- فرش‌ها --- func (h *Handler) apiCarpets(w http.ResponseWriter, r *http.Request) { httpx.JSON(w, http.StatusOK, map[string]any{ "carpets": h.rows(r.Context(), `SELECT id,title,price_coins,vip,sort,enabled FROM carpets ORDER BY sort`, "id", "title", "price_coins", "vip", "sort", "enabled"), }) } func (h *Handler) apiCarpetUpsert(w http.ResponseWriter, r *http.Request) { var b struct { ID string `json:"id"` Title string `json:"title"` PriceCoins int64 `json:"price_coins"` VIP bool `json:"vip"` Sort int `json:"sort"` } if err := json.NewDecoder(r.Body).Decode(&b); err != nil || strings.TrimSpace(b.ID) == "" || strings.ContainsAny(b.ID, `/\.`) { httpx.Error(w, http.StatusBadRequest, "invalid body") return } vip := 0 if b.VIP { vip = 1 } if _, err := h.db.ExecContext(r.Context(), `INSERT OR REPLACE INTO carpets (id,title,price_coins,vip,sort,enabled) VALUES (?,?,?,?,?,1)`, b.ID, b.Title, b.PriceCoins, vip, b.Sort); err != nil { httpx.Error(w, http.StatusInternalServerError, err.Error()) return } httpx.JSON(w, http.StatusOK, map[string]any{"ok": true}) } func (h *Handler) apiCarpetDelete(w http.ResponseWriter, r *http.Request) { _, _ = h.db.ExecContext(r.Context(), `DELETE FROM carpets WHERE id=?`, r.URL.Query().Get("id")) httpx.JSON(w, http.StatusOK, map[string]any{"ok": true}) } // --- قاب‌های آواتار --- func (h *Handler) apiFrames(w http.ResponseWriter, r *http.Request) { httpx.JSON(w, http.StatusOK, map[string]any{ "frames": h.rows(r.Context(), `SELECT id,title,price_coins,vip,sort,enabled FROM avatar_frames ORDER BY sort`, "id", "title", "price_coins", "vip", "sort", "enabled"), }) } func (h *Handler) apiFrameUpsert(w http.ResponseWriter, r *http.Request) { var b struct { ID string `json:"id"` Title string `json:"title"` PriceCoins int64 `json:"price_coins"` VIP bool `json:"vip"` Sort int `json:"sort"` Enabled *bool `json:"enabled"` } if err := json.NewDecoder(r.Body).Decode(&b); err != nil || strings.TrimSpace(b.ID) == "" { httpx.Error(w, http.StatusBadRequest, "invalid body") return } vip := 0 if b.VIP { vip = 1 } enabled := 1 if b.Enabled != nil && !*b.Enabled { enabled = 0 } if _, err := h.db.ExecContext(r.Context(), `INSERT OR REPLACE INTO avatar_frames (id,title,price_coins,vip,sort,enabled) VALUES (?,?,?,?,?,?)`, b.ID, b.Title, b.PriceCoins, vip, b.Sort, enabled); err != nil { httpx.Error(w, http.StatusInternalServerError, err.Error()) return } httpx.JSON(w, http.StatusOK, map[string]any{"ok": true}) } func (h *Handler) apiFrameDelete(w http.ResponseWriter, r *http.Request) { _, _ = h.db.ExecContext(r.Context(), `DELETE FROM avatar_frames WHERE id=?`, r.URL.Query().Get("id")) httpx.JSON(w, http.StatusOK, map[string]any{"ok": true}) } // --- چت --- func (h *Handler) apiChat(w http.ResponseWriter, r *http.Request) { ctx := r.Context() httpx.JSON(w, http.StatusOK, map[string]any{ "packs": h.rows(ctx, `SELECT id,title,kind,price_coins,vip,sort,enabled FROM chat_packs ORDER BY sort`, "id", "title", "kind", "price_coins", "vip", "sort", "enabled"), "messages": h.rows(ctx, `SELECT id,pack_id,body,sort FROM chat_messages ORDER BY pack_id,sort`, "id", "pack_id", "body", "sort"), }) } func (h *Handler) apiChatPackUpsert(w http.ResponseWriter, r *http.Request) { var b struct { ID string `json:"id"` Title string `json:"title"` Kind string `json:"kind"` PriceCoins int64 `json:"price_coins"` VIP bool `json:"vip"` Sort int `json:"sort"` } if err := json.NewDecoder(r.Body).Decode(&b); err != nil || strings.TrimSpace(b.ID) == "" { httpx.Error(w, http.StatusBadRequest, "invalid body") return } if b.Kind != "emoji" { b.Kind = "text" } vip := 0 if b.VIP { vip = 1 } if _, err := h.db.ExecContext(r.Context(), `INSERT OR REPLACE INTO chat_packs (id,title,kind,price_coins,vip,sort,enabled) VALUES (?,?,?,?,?,?,1)`, b.ID, b.Title, b.Kind, b.PriceCoins, vip, b.Sort); err != nil { httpx.Error(w, http.StatusInternalServerError, err.Error()) return } httpx.JSON(w, http.StatusOK, map[string]any{"ok": true}) } func (h *Handler) apiChatPackDelete(w http.ResponseWriter, r *http.Request) { id := r.URL.Query().Get("id") _, _ = h.db.ExecContext(r.Context(), `DELETE FROM chat_messages WHERE pack_id=?`, id) _, _ = h.db.ExecContext(r.Context(), `DELETE FROM chat_packs WHERE id=?`, id) httpx.JSON(w, http.StatusOK, map[string]any{"ok": true}) } func (h *Handler) apiChatMessageAdd(w http.ResponseWriter, r *http.Request) { var b struct { PackID string `json:"pack_id"` Body string `json:"body"` Sort int `json:"sort"` } if err := json.NewDecoder(r.Body).Decode(&b); err != nil || b.PackID == "" || b.Body == "" { httpx.Error(w, http.StatusBadRequest, "invalid body") return } if _, err := h.db.ExecContext(r.Context(), `INSERT INTO chat_messages (pack_id,body,sort) VALUES (?,?,?)`, b.PackID, b.Body, b.Sort); err != nil { httpx.Error(w, http.StatusInternalServerError, err.Error()) return } httpx.JSON(w, http.StatusOK, map[string]any{"ok": true}) } func (h *Handler) apiChatMessageDelete(w http.ResponseWriter, r *http.Request) { id, _ := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64) _, _ = h.db.ExecContext(r.Context(), `DELETE FROM chat_messages WHERE id=?`, id) httpx.JSON(w, http.StatusOK, map[string]any{"ok": true}) } // --- تورنومنت‌ها --- func (h *Handler) apiTournaments(w http.ResponseWriter, r *http.Request) { list, _ := h.eco.AdminListTournaments(r.Context()) httpx.JSON(w, http.StatusOK, map[string]any{"tournaments": list}) } type tournamentBody struct { ID int64 `json:"id"` Title string `json:"title"` Description string `json:"description"` EntryFee int64 `json:"entry_fee"` Prizes []int64 `json:"prizes"` StartsAt string `json:"starts_at"` // datetime-local به وقتِ تهران EndsAt string `json:"ends_at"` } // tournamentFromBody بدنه را به economy.Tournament (با زمانِ UTC) تبدیل می‌کند. func tournamentFromBody(b tournamentBody) economy.Tournament { iran := time.FixedZone("IRST", 12600) parse := func(s string) time.Time { t, err := time.ParseInLocation("2006-01-02T15:04", s, iran) if err != nil { return time.Now().UTC() } return t.UTC() } return economy.Tournament{ Title: b.Title, Description: b.Description, EntryFee: b.EntryFee, Prizes: b.Prizes, StartsAt: parse(b.StartsAt), EndsAt: parse(b.EndsAt), } } func (h *Handler) apiTournamentAdd(w http.ResponseWriter, r *http.Request) { var b tournamentBody if err := json.NewDecoder(r.Body).Decode(&b); err != nil || strings.TrimSpace(b.Title) == "" { httpx.Error(w, http.StatusBadRequest, "invalid body") return } if _, err := h.eco.CreateTournament(r.Context(), tournamentFromBody(b)); err != nil { httpx.Error(w, http.StatusInternalServerError, err.Error()) return } httpx.JSON(w, http.StatusOK, map[string]any{"ok": true}) } func (h *Handler) apiTournamentUpdate(w http.ResponseWriter, r *http.Request) { var b tournamentBody if err := json.NewDecoder(r.Body).Decode(&b); err != nil || b.ID == 0 || strings.TrimSpace(b.Title) == "" { httpx.Error(w, http.StatusBadRequest, "invalid body") return } if err := h.eco.UpdateTournament(r.Context(), b.ID, tournamentFromBody(b)); err != nil { httpx.Error(w, http.StatusInternalServerError, err.Error()) return } httpx.JSON(w, http.StatusOK, map[string]any{"ok": true}) } func (h *Handler) apiTournamentDelete(w http.ResponseWriter, r *http.Request) { id, _ := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64) _ = h.eco.DeleteTournament(r.Context(), id) httpx.JSON(w, http.StatusOK, map[string]any{"ok": true}) } func (h *Handler) apiSeasonReset(w http.ResponseWriter, r *http.Request) { if err := h.eco.ResetSeason(r.Context()); err != nil { httpx.Error(w, http.StatusInternalServerError, err.Error()) return } httpx.JSON(w, http.StatusOK, map[string]any{"ok": true}) } // coerceCol مقدارِ JSON را برای ستونِ کاتالوگ به نوعِ درست تبدیل می‌کند. func coerceCol(col string, v any) any { switch col { case "id", "title": s, _ := v.(string) return s case "enabled": return boolToInt(v) default: // اعداد return toInt(v) } } func toInt(v any) int64 { switch n := v.(type) { case float64: return int64(n) case int64: return n case int: return int64(n) case string: i, _ := strconv.ParseInt(strings.TrimSpace(n), 10, 64) return i } return 0 } func boolToInt(v any) int { switch b := v.(type) { case bool: if b { return 1 } case float64: if b != 0 { return 1 } case string: if b == "true" || b == "1" || b == "on" { return 1 } } return 0 }