diff --git a/deploy.sh b/deploy.sh index 8f69a99..8a65bad 100755 --- a/deploy.sh +++ b/deploy.sh @@ -3,7 +3,7 @@ # بدونِ رمز (کلیدِ SSH). اجرا: ./deploy.sh set -euo pipefail -HOST="${HAKEM_HOST:-ubuntu@95.38.179.159}" +HOST="${HAKEM_HOST:-ubuntu@37.32.20.103}" KEY="${HAKEM_KEY:-$HOME/.ssh/hakem_deploy}" REMOTE_DIR="/home/ubuntu/hakemsho" diff --git a/internal/admin/api.go b/internal/admin/api.go index 0eb9aaa..072cae1 100644 --- a/internal/admin/api.go +++ b/internal/admin/api.go @@ -30,6 +30,8 @@ func (h *Handler) APIRoutes() http.Handler { r.Post("/users/coins", h.apiUserCoins) r.Post("/users/grant", h.apiUserGrant) + r.Get("/purchases", h.apiPurchases) + r.Get("/carpets", h.apiCarpets) r.Post("/carpets", h.apiCarpetUpsert) r.Delete("/carpets", h.apiCarpetDelete) @@ -73,13 +75,18 @@ func (h *Handler) apiDashboard(w http.ResponseWriter, r *http.Request) { _ = h.db.QueryRowContext(ctx, q).Scan(&n) return n } + stats := h.purchasesStats(ctx) 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), + "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), + "revenue": stats["total_revenue"], + "revenue_today": stats["today"], + "revenue_14d": stats["last_14_days"], + "recent": h.purchaseRows(ctx, ` WHERE p.status = 'verified'`, nil, 8, 0), }) } diff --git a/internal/admin/purchases.go b/internal/admin/purchases.go new file mode 100644 index 0000000..86cc615 --- /dev/null +++ b/internal/admin/purchases.go @@ -0,0 +1,236 @@ +package admin + +import ( + "context" + "net/http" + "strconv" + "strings" + "time" + + "hakemsho/internal/httpx" +) + +// iranTZ منطقه‌ی زمانیِ ایران (UTC+3:30؛ بدون DST از ۲۰۲۲). همین الگو در admin.go +// و api.go به‌صورت inline استفاده شده؛ اینجا در سطح پکیج برای چند نقطهِ مشترک. +var iranTZ = time.FixedZone("IRST", 12600) + +const tsLayout = "2006-01-02 15:04:05" + +// apiPurchases فهرستِ تراکنش‌های خرید را به‌همراه آمارهای کلی برمی‌گرداند. +// پارامترها: q (موبایل/نام/شناسه کاربر)، store، kind، status، user_id، +// page (پیش‌فرض ۱) و page_size (پیش‌فرض ۵۰، سقف ۲۰۰). +func (h *Handler) apiPurchases(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + q := strings.TrimSpace(r.URL.Query().Get("q")) + store := strings.TrimSpace(r.URL.Query().Get("store")) + kind := strings.TrimSpace(r.URL.Query().Get("kind")) + status := strings.TrimSpace(r.URL.Query().Get("status")) + userID, _ := strconv.ParseInt(r.URL.Query().Get("user_id"), 10, 64) + + page := atoiDefault(r.URL.Query().Get("page"), 1) + if page < 1 { + page = 1 + } + pageSize := atoiDefault(r.URL.Query().Get("page_size"), 50) + if pageSize < 1 { + pageSize = 50 + } + if pageSize > 200 { + pageSize = 200 + } + + var where []string + var args []any + if q != "" { + like := "%" + q + "%" + where = append(where, `(u.mobile LIKE ? OR u.first_name LIKE ? OR CAST(p.user_id AS TEXT) = ?)`) + args = append(args, like, like, q) + } + if store != "" { + where = append(where, `p.store = ?`) + args = append(args, store) + } + if kind != "" { + where = append(where, `p.kind = ?`) + args = append(args, kind) + } + if status != "" { + where = append(where, `p.status = ?`) + args = append(args, status) + } + if userID > 0 { + where = append(where, `p.user_id = ?`) + args = append(args, userID) + } + whereSQL := "" + if len(where) > 0 { + whereSQL = " WHERE " + strings.Join(where, " AND ") + } + + var total int64 + _ = h.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM purchases p LEFT JOIN users u ON u.id = p.user_id`+whereSQL, args...).Scan(&total) + + list := h.purchaseRows(ctx, whereSQL, args, pageSize, (page-1)*pageSize) + + pages := int64(1) + if pageSize > 0 { + pages = (total + int64(pageSize) - 1) / int64(pageSize) + } + if pages < 1 { + pages = 1 + } + + httpx.JSON(w, http.StatusOK, map[string]any{ + "purchases": list, + "stats": h.purchasesStats(ctx), + "total": total, + "page": page, + "page_size": pageSize, + "pages": pages, + }) +} + +// purchaseRows فهرستِ خریدها را با مشخصاتِ کاربر برمی‌گرداند و روی هر ردیف +// «عنوانِ محصول» و «زمانِ تهران» را اضافه می‌کند. +func (h *Handler) purchaseRows(ctx context.Context, whereSQL string, args []any, limit, offset int) []map[string]any { + q := `SELECT p.id, p.user_id, COALESCE(u.mobile,''), COALESCE(u.first_name,''), p.store, p.kind, p.product_id, + p.coins, p.tickets, p.vip_days, p.price_toman, p.status, p.created_at + FROM purchases p LEFT JOIN users u ON u.id = p.user_id` + + whereSQL + ` ORDER BY p.id DESC LIMIT ? OFFSET ?` + qArgs := append(append([]any{}, args...), limit, offset) + + rows := h.rowsArgs(ctx, q, qArgs, + "id", "user_id", "mobile", "name", "store", "kind", "product_id", + "coins", "tickets", "vip_days", "price_toman", "status", "created_at") + + titles := h.productTitles() + for _, row := range rows { + kind, _ := row["kind"].(string) + pid, _ := row["product_id"].(string) + row["product_title"] = titles[kind+":"+pid] + if s, _ := row["product_title"].(string); s == "" { + row["product_title"] = pid + } + if raw, _ := row["created_at"].(string); raw != "" { + row["created_local"] = toTehran(raw) + } else { + row["created_local"] = "" + } + } + return rows +} + +// productTitles نگاشتِ «نوع:شناسه» → عنوانِ محصول را از کاتالوگِ کش‌شده می‌سازد. +func (h *Handler) productTitles() map[string]string { + c := h.eco.Catalog() + m := make(map[string]string) + for _, p := range c.CoinPackages { + m["coin:"+p.ID] = p.Title + } + for _, p := range c.TicketPackages { + m["ticket:"+p.ID] = p.Title + } + for _, p := range c.Boosters { + m["booster:"+p.ID] = p.Title + } + for _, p := range c.VIPPackages { + m["vip:"+p.ID] = p.Title + } + return m +} + +// purchasesStats آمارِ کلیِ خریدهای تأییدشده (برای کارت‌های بالای پنلِ تراکنش‌ها). +// بازه‌های «امروز/این ماه/۱۴ روز» با نیمه‌شبِ تهران در Go محاسبه و با رشته‌ی UTC +// مقایسه می‌شوند تا به تفاوتِ منطقه‌ی زمانیِ SQLite وابسته نباشیم. +func (h *Handler) purchasesStats(ctx context.Context) map[string]any { + now := time.Now() + tehranNow := now.In(iranTZ) + dayStart := tehranNow.UTC().Truncate(24 * time.Hour).Format(tsLayout) + monthStart := time.Date(tehranNow.Year(), tehranNow.Month(), 1, 0, 0, 0, 0, iranTZ).UTC().Format(tsLayout) + weekAgo := now.UTC().AddDate(0, 0, -13).Format(tsLayout) + + agg := func(since string) (int64, int64) { + var n, sum int64 + q := `SELECT COUNT(*), COALESCE(SUM(price_toman),0) FROM purchases WHERE status='verified'` + var args []any + if since != "" { + q += ` AND created_at >= ?` + args = append(args, since) + } + _ = h.db.QueryRowContext(ctx, q, args...).Scan(&n, &sum) + return n, sum + } + + stat := func(since string) map[string]any { + n, sum := agg(since) + return map[string]any{"count": n, "revenue": sum} + } + + totalN, totalSum := agg("") + var avg int64 + if totalN > 0 { + avg = totalSum / totalN + } + var buyers int64 + _ = h.db.QueryRowContext(ctx, + `SELECT COUNT(DISTINCT user_id) FROM purchases WHERE status='verified'`).Scan(&buyers) + + // ۱۴ روزِ گذشته (تهران): یک کوئریِ محدود و جمع‌بندی در Go. + days := make(map[string]int64, 14) + counts := make(map[string]int64, 14) + rs, err := h.db.QueryContext(ctx, + `SELECT created_at, price_toman FROM purchases WHERE status='verified' AND created_at >= ?`, weekAgo) + if err == nil { + defer rs.Close() + for rs.Next() { + var raw string + var price int64 + if rs.Scan(&raw, &price) != nil { + continue + } + d := "?" + if t, err := time.Parse(tsLayout, raw); err == nil { + d = t.In(iranTZ).Format("2006-01-02") + } + days[d] += price + counts[d]++ + } + } + var last14 []map[string]any + for i := 13; i >= 0; i-- { + d := tehranNow.AddDate(0, 0, -i).Format("2006-01-02") + last14 = append(last14, map[string]any{ + "date": d, + "revenue": days[d], + "count": counts[d], + }) + } + + return map[string]any{ + "total_revenue": totalSum, + "verified_count": totalN, + "unique_buyers": buyers, + "avg_revenue": avg, + "today": stat(dayStart), + "month": stat(monthStart), + "last_14_days": last14, + } +} + +// toTehran رشته‌ی زمانیِ UTC را به «زمانِ تهران» با همان قالب تبدیل می‌کند. +func toTehran(raw string) string { + t, err := time.Parse(tsLayout, raw) + if err != nil { + return raw + } + return t.In(iranTZ).Format(tsLayout) +} + +func atoiDefault(s string, def int) int { + n, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil { + return def + } + return n +} diff --git a/internal/economy/economy.go b/internal/economy/economy.go index 05848a2..097a62e 100644 --- a/internal/economy/economy.go +++ b/internal/economy/economy.go @@ -351,6 +351,7 @@ func (s *Service) VerifyPurchase(ctx context.Context, userID int64, req Purchase // را به‌دست آوریم؛ سپس با همان SKU خرید را تأیید می‌کنیم. var coins, tickets int64 var vipDays int + var priceToman int var booster *Booster var sku string switch req.Kind { @@ -359,28 +360,28 @@ func (s *Service) VerifyPurchase(ctx context.Context, userID int64, req Purchase if p == nil { return ErrNotFound } - coins, vipDays, sku = p.Coins, p.VIPDays, p.SKU + coins, vipDays, priceToman, sku = p.Coins, p.VIPDays, p.PriceToman, p.SKU if s.isVIP(ctx, userID) { - coins += coins / 10 // پاداش ۱۰٪ VIP + coins += coins / 10 // پاداش ۱۰٪ VIP (قیمت را تغییر نمی‌دهد) } case "ticket": p := s.findTicketPackage(req.ProductID) if p == nil { return ErrNotFound } - tickets, sku = p.Tickets, p.SKU + tickets, priceToman, sku = p.Tickets, p.PriceToman, p.SKU case "booster": booster = s.findBooster(req.ProductID) if booster == nil { return ErrNotFound } - sku = booster.SKU + priceToman, sku = booster.PriceToman, booster.SKU case "vip": p := s.findVIPPackage(req.ProductID) if p == nil { return ErrNotFound } - vipDays, sku = p.Months*30, p.SKU // هر ماه = ۳۰ روز + vipDays, priceToman, sku = p.Months*30, p.PriceToman, p.SKU // هر ماه = ۳۰ روز default: return ErrNotFound } @@ -407,9 +408,9 @@ func (s *Service) VerifyPurchase(ctx context.Context, userID int64, req Purchase // ثبت خرید (یکتا بودن store+token ⇒ ضد دوباره‌اعتبارسنجی) if _, err := tx.ExecContext(ctx, - `INSERT INTO purchases (user_id, store, product_id, purchase_token, status, coins, tickets, vip_days) - VALUES (?, ?, ?, ?, 'verified', ?, ?, ?)`, - userID, req.Store, req.ProductID, req.Token, coins, tickets, vipDays); err != nil { + `INSERT INTO purchases (user_id, store, product_id, purchase_token, status, coins, tickets, vip_days, kind, price_toman) + VALUES (?, ?, ?, ?, 'verified', ?, ?, ?, ?, ?)`, + userID, req.Store, req.ProductID, req.Token, coins, tickets, vipDays, req.Kind, priceToman); err != nil { if isUnique(err) { return ErrAlreadyOwned // قبلاً پردازش شده } diff --git a/internal/economy/economy_test.go b/internal/economy/economy_test.go index f1b8f88..495f298 100644 --- a/internal/economy/economy_test.go +++ b/internal/economy/economy_test.go @@ -192,6 +192,16 @@ func TestPurchaseCreditsAndIdempotent(t *testing.T) { if c := coinsOf(t, svc, id); c != pkg.Coins { t.Fatalf("want %d coins, got %d", pkg.Coins, c) } + // نوع و قیمتِ خرید باید ثبت شده باشند (پنلِ تراکنش‌ها). + var kind string + var priceToman int + if err := svc.db.QueryRow(`SELECT kind, price_toman FROM purchases WHERE user_id=?`, id). + Scan(&kind, &priceToman); err != nil { + t.Fatalf("read purchase: %v", err) + } + if kind != "coin" || priceToman != pkg.PriceToman { + t.Fatalf("want kind=coin price=%d, got kind=%q price=%d", pkg.PriceToman, kind, priceToman) + } // همان توکن دوباره ⇒ قبلاً پردازش شده، بدون شارژ مجدد if err := svc.VerifyPurchase(ctx, id, req); err != ErrAlreadyOwned { t.Fatalf("want ErrAlreadyOwned, got %v", err) diff --git a/internal/store/migrations/018_purchase_price.sql b/internal/store/migrations/018_purchase_price.sql new file mode 100644 index 0000000..ab73c06 --- /dev/null +++ b/internal/store/migrations/018_purchase_price.sql @@ -0,0 +1,32 @@ +-- تراکنش‌ها: ثبتِ نوع محصول (kind) و قیمتِ پرداخت‌شده (price_toman) در خریدها. +-- تا پیش از این، خرید فقط بسته‌ی دریافتی (سکه/بلیط/VIP) را ثبت می‌کرد؛ نه نوع و نه +-- مبلغ را. این دو ستون برای پنلِ «تراکنش‌ها» لازم است و از این پس هنگامِ ثبتِ خرید +-- پر می‌شوند. ردیف‌های قدیمی با توجه به کاتالوگِ فعلی backfill می‌شوند. + +ALTER TABLE purchases ADD COLUMN kind TEXT NOT NULL DEFAULT ''; +ALTER TABLE purchases ADD COLUMN price_toman INTEGER NOT NULL DEFAULT 0; + +-- Backfill نوع: اولویت سکه → بلیط → VIP → بوستر. ردیف‌های غیرِ verified هیچ +-- بسته‌ای نگرفته‌اند پس نوعشان نامعلوم می‌ماند ('' نه 'booster' تا دوپاره نشود). +UPDATE purchases SET kind = CASE + WHEN status != 'verified' THEN '' + WHEN coins > 0 THEN 'coin' + WHEN tickets > 0 THEN 'ticket' + WHEN vip_days > 0 THEN 'vip' + ELSE 'booster' +END +WHERE kind = ''; + +-- Backfill قیمت؛ با توجه به نوع، چون شناسه‌ی محصول بین جدول‌های کاتالوگ مشترک است +-- (مثلاً 'small' هم در coin_packages هست و هم در ticket_packages). محصولِ حذف‌شده → ۰. +UPDATE purchases SET price_toman = CASE kind + WHEN 'coin' THEN COALESCE((SELECT price_toman FROM coin_packages WHERE id = purchases.product_id), 0) + WHEN 'ticket' THEN COALESCE((SELECT price_toman FROM ticket_packages WHERE id = purchases.product_id), 0) + WHEN 'booster' THEN COALESCE((SELECT price_toman FROM boosters WHERE id = purchases.product_id), 0) + WHEN 'vip' THEN COALESCE((SELECT price_toman FROM vip_packages WHERE id = purchases.product_id), 0) + ELSE 0 +END +WHERE price_toman = 0; + +-- ایندکس برای فیلترِ وضعیت + آمارِ روزانه/ماهانه در پنلِ تراکنش‌ها. +CREATE INDEX IF NOT EXISTS idx_purchases_status_created ON purchases(status, created_at);