Files
back-hokm/internal/admin/api.go
T
2026-07-08 12:34:21 +03:30

526 lines
18 KiB
Go

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
}