237 lines
7.2 KiB
Go
237 lines
7.2 KiB
Go
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
|
||
}
|