From e8396db45c160d3650e33917a985e8a9967a334a Mon Sep 17 00:00:00 2001 From: Amirmahdi Nourkazemi Date: Tue, 7 Jul 2026 17:21:08 +0330 Subject: [PATCH] feat: add admin routes --- internal/admin/admin.go | 9 +- internal/admin/api.go | 476 +++++++++++++++++++++++++++++++++ internal/economy/tournament.go | 11 + 3 files changed, 495 insertions(+), 1 deletion(-) create mode 100644 internal/admin/api.go diff --git a/internal/admin/admin.go b/internal/admin/admin.go index e18936d..e5d4605 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -67,6 +67,8 @@ func (h *Handler) Routes(user, pass string) http.Handler { r.Get("/tournaments", h.tournamentsAdmin) r.Post("/tournaments/add", h.tournamentAdd) r.Post("/tournaments/delete", h.tournamentDelete) + // APIِ JSON برای پنلِ Next.js (پشتِ همان Basic Auth). + r.Mount("/api", h.APIRoutes()) return r } @@ -661,7 +663,12 @@ func newUUID() string { } func (h *Handler) rows(ctx context.Context, query string, cols ...string) []map[string]any { - rs, err := h.db.QueryContext(ctx, query) + return h.rowsArgs(ctx, query, nil, cols...) +} + +// rowsArgs مثلِ rows اما با پارامترهای کوئری. +func (h *Handler) rowsArgs(ctx context.Context, query string, args []any, cols ...string) []map[string]any { + rs, err := h.db.QueryContext(ctx, query, args...) if err != nil { return nil } diff --git a/internal/admin/api.go b/internal/admin/api.go new file mode 100644 index 0000000..d5a728f --- /dev/null +++ b/internal/admin/api.go @@ -0,0 +1,476 @@ +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("/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) 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 +} diff --git a/internal/economy/tournament.go b/internal/economy/tournament.go index 083d52c..c4d277e 100644 --- a/internal/economy/tournament.go +++ b/internal/economy/tournament.go @@ -351,6 +351,17 @@ func (s *Service) CreateTournament(ctx context.Context, t Tournament) (int64, er return res.LastInsertId() } +// UpdateTournament فیلدهای یک تورنومنت را ویرایش می‌کند (ثبت‌نام‌ها دست‌نخورده). +func (s *Service) UpdateTournament(ctx context.Context, id int64, t Tournament) error { + prizes, _ := json.Marshal(t.Prizes) + _, err := s.db.ExecContext(ctx, + `UPDATE tournaments SET title=?, description=?, entry_fee=?, prizes=?, starts_at=?, ends_at=? + WHERE id=?`, + t.Title, t.Description, t.EntryFee, string(prizes), + t.StartsAt.UTC().Format(tsLayout), t.EndsAt.UTC().Format(tsLayout), id) + return err +} + // DeleteTournament یک تورنومنت و ثبت‌نام‌هایش را حذف می‌کند. func (s *Service) DeleteTournament(ctx context.Context, id int64) error { if _, err := s.db.ExecContext(ctx, `DELETE FROM tournament_entries WHERE tournament_id = ?`, id); err != nil {