feat: add cards templete

This commit is contained in:
2026-06-24 10:47:54 +03:30
parent 60494946d6
commit b1c7bfdd06
124 changed files with 422 additions and 30 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/server ./cmd/serv
FROM gcr.io/distroless/static-debian12
WORKDIR /app
COPY --from=build /out/server /app/server
ENV ADDR=:8080 DB_PATH=/data/hakemsho.db
ENV ADDR=:8080 DB_PATH=/data/hakemsho.db CARDS_DIR=/data/cards
EXPOSE 8080
VOLUME ["/data"]
USER nonroot:nonroot
+6 -1
View File
@@ -16,6 +16,7 @@ import (
"hakemsho/internal/config"
"hakemsho/internal/economy"
"hakemsho/internal/httpx"
"hakemsho/internal/static"
"hakemsho/internal/store"
"hakemsho/internal/user"
"hakemsho/internal/ws"
@@ -80,8 +81,11 @@ func main() {
// WebSocket بازی (احراز هویت با توکن در ?token= یا هدر Authorization)
r.Get("/ws", hub.ServeWS)
// تصاویرِ کارت (هر اسکین یک پوشه) — از backend سرویس می‌شود تا اپ سبک بماند.
r.Handle("/cards/*", static.CardsHandler(cfg.CardsDir))
// پنل ادمین (HTML سرور‌ساید، پشت Basic Auth)
r.Mount("/admin", admin.New(st.DB, eco).Routes(cfg.AdminUser, cfg.AdminPass))
r.Mount("/admin", admin.New(st.DB, eco, cfg.CardsDir).Routes(cfg.AdminUser, cfg.AdminPass))
r.Route("/api", func(r chi.Router) {
r.Route("/auth", func(r chi.Router) {
@@ -97,6 +101,7 @@ func main() {
// کیف‌پول و فروشگاه
r.Get("/wallet", ecoH.Wallet)
r.Get("/stats", ecoH.Stats)
r.Get("/leaderboard", ecoH.Leaderboard)
r.Get("/tables/info", ecoH.TablesInfo)
r.Get("/shop", ecoH.Shop)
r.Post("/shop/buy-card", ecoH.BuyCard)
+20
View File
@@ -41,6 +41,17 @@ func (g gameSettler) AwardWinner(userID int64, tier string) {
}
}
// AwardRank امتیازِ رتبه را در میزِ رتبه‌بندی به‌روزرسانی می‌کند.
func (g gameSettler) AwardRank(userID int64, tier string, won bool) {
t := g.eco.FindTier(tier)
if t == nil || t.RankReward <= 0 {
return
}
if err := g.eco.AwardRank(context.Background(), userID, t.RankReward, won); err != nil {
slog.Error("award rank", "user", userID, "err", err)
}
}
func (g gameSettler) RecordGame(room, playersJSON string, winnerTeam int, kot bool) {
if err := g.eco.RecordGame(context.Background(), room, playersJSON, winnerTeam, kot); err != nil {
slog.Error("record game", "room", room, "err", err)
@@ -62,6 +73,15 @@ func (g gameSettler) RecordGameResult(userID int64, won bool) {
g.eco.RecordGameResult(context.Background(), userID, won)
}
// PlayerProfile سکه و نشانِ رتبه‌ی بازیکن را برای نمایش سرِ میز برمی‌گرداند.
func (g gameSettler) PlayerProfile(userID int64) (int64, string) {
p, err := g.eco.GetProfile(context.Background(), userID)
if err != nil || p == nil {
return 0, ""
}
return p.Coins, p.RankTier
}
// ChargePrivateTable یک میز خصوصیِ رایگان را برای کاربر مصرف می‌کند.
func (g gameSettler) ChargePrivateTable(userID int64) error {
return g.eco.ConsumePrivateTable(context.Background(), userID)
+131 -8
View File
@@ -3,12 +3,18 @@
package admin
import (
"archive/zip"
"context"
"database/sql"
"embed"
"fmt"
"html/template"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
@@ -23,11 +29,12 @@ type Handler struct {
db *sql.DB
eco *economy.Service
tpl *template.Template
cardsDir string // پوشه‌ی دیسکیِ آپلودِ اسکین‌ها (همان مسیری که static از آن سرو می‌کند)
}
func New(db *sql.DB, eco *economy.Service) *Handler {
func New(db *sql.DB, eco *economy.Service, cardsDir string) *Handler {
tpl := template.Must(template.ParseFS(tplFS, "templates/*.html"))
return &Handler{db: db, eco: eco, tpl: tpl}
return &Handler{db: db, eco: eco, tpl: tpl, cardsDir: cardsDir}
}
// Routes زیرروترِ /admin را با Basic Auth برمی‌گرداند.
@@ -38,10 +45,12 @@ func (h *Handler) Routes(user, pass string) http.Handler {
r.Get("/shop", h.shop)
r.Post("/shop/{kind}/add", h.addItem)
r.Post("/shop/{kind}/delete", h.deleteItem)
r.Post("/shop/card/{id}/upload", h.uploadDeck)
r.Post("/shop/{kind}", h.updateItem)
r.Get("/users", h.users)
r.Post("/users/coins", h.adjustCoins)
r.Post("/users/grant", h.grantToUser)
r.Post("/season/reset", h.resetSeason)
return r
}
@@ -66,6 +75,8 @@ func (h *Handler) dashboard(w http.ResponseWriter, r *http.Request) {
"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`),
"Season": h.eco.Season(ctx),
"Saved": r.URL.Query().Get("saved") == "1",
"Nav": "dashboard",
}
h.render(w, "dashboard.html", data)
@@ -87,8 +98,8 @@ func (h *Handler) shop(w http.ResponseWriter, r *http.Request) {
"id", "title", "multiplier", "hours", "price_toman", "sort", "enabled"),
"VIP": h.rows(ctx, `SELECT id,title,months,price_toman,sort,enabled FROM vip_packages ORDER BY sort`,
"id", "title", "months", "price_toman", "sort", "enabled"),
"Tiers": h.rows(ctx, `SELECT id,title,hands,entry,prize,xp,trophy,sort,enabled FROM table_tiers ORDER BY sort`,
"id", "title", "hands", "entry", "prize", "xp", "trophy", "sort", "enabled"),
"Tiers": 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"),
"Saved": r.URL.Query().Get("saved") == "1",
}
h.render(w, "shop.html", data)
@@ -127,8 +138,8 @@ func (h *Handler) updateItem(w http.ResponseWriter, r *http.Request) {
q = `UPDATE vip_packages SET title=?,months=?,price_toman=?,sort=?,enabled=? WHERE id=?`
args = []any{r.FormValue("title"), f("months"), f("price_toman"), f("sort"), en, id}
case "tier":
q = `UPDATE table_tiers SET title=?,hands=?,entry=?,prize=?,xp=?,trophy=?,sort=?,enabled=? WHERE id=?`
args = []any{r.FormValue("title"), f("hands"), f("entry"), f("prize"), f("xp"), f("trophy"), f("sort"), en, id}
q = `UPDATE table_tiers SET title=?,hands=?,entry=?,prize=?,xp=?,trophy=?,rank_reward=?,sort=?,enabled=? WHERE id=?`
args = []any{r.FormValue("title"), f("hands"), f("entry"), f("prize"), f("xp"), f("trophy"), f("rank_reward"), f("sort"), en, id}
default:
http.Error(w, "unknown kind", http.StatusBadRequest)
return
@@ -179,8 +190,8 @@ func (h *Handler) addItem(w http.ResponseWriter, r *http.Request) {
q = `INSERT INTO vip_packages (id,title,months,price_toman,sort,enabled) VALUES (?,?,?,?,?,?)`
args = []any{id, r.FormValue("title"), f("months"), f("price_toman"), f("sort"), en}
case "tier":
q = `INSERT INTO table_tiers (id,title,hands,entry,prize,xp,trophy,sort,enabled) VALUES (?,?,?,?,?,?,?,?,?)`
args = []any{id, r.FormValue("title"), f("hands"), f("entry"), f("prize"), f("xp"), f("trophy"), f("sort"), en}
q = `INSERT INTO table_tiers (id,title,hands,entry,prize,xp,trophy,rank_reward,sort,enabled) VALUES (?,?,?,?,?,?,?,?,?,?)`
args = []any{id, r.FormValue("title"), f("hands"), f("entry"), f("prize"), f("xp"), f("trophy"), f("rank_reward"), f("sort"), en}
default:
http.Error(w, "unknown kind", http.StatusBadRequest)
return
@@ -194,6 +205,109 @@ func (h *Handler) addItem(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/shop?saved=1", http.StatusSeeOther)
}
// validCardFile نامِ کانونیِ خروجی را برای یک ورودیِ zip برمی‌گرداند (یا ok=false).
// مجاز: back.(jpg|png) → "back.jpg" و <کد>.png → "<کدِ بزرگ>.png"
// که <کد> = رتبه (A,2..10,J,Q,K) + خال (S,H,D,C)، مثل AS.png یا 10H.png.
// خروجی همیشه «کدِ بزرگ + پسوندِ کوچک» است تا با درخواست‌های اپ روی فایل‌سیستمِ
// حساس‌به‌حروف (لینوکس) دقیقاً مطابقت کند.
func validCardFile(name string) (string, bool) {
base := filepath.Base(name)
low := strings.ToLower(base)
if low == "back.jpg" || low == "back.png" {
return "back.jpg", true // اپ پشتِ کارت را با نامِ back.jpg می‌خواهد
}
if !strings.HasSuffix(low, ".png") {
return "", false
}
code := strings.ToUpper(base[:len(base)-len(".png")])
suits := "SHDC"
if len(code) < 2 || !strings.Contains(suits, code[len(code)-1:]) {
return "", false
}
ranks := map[string]bool{"A": true, "2": true, "3": true, "4": true, "5": true,
"6": true, "7": true, "8": true, "9": true, "10": true, "J": true, "Q": true, "K": true}
if !ranks[code[:len(code)-1]] {
return "", false
}
return code + ".png", true
}
// uploadDeck یک فایلِ zip شاملِ تصاویرِ کارت را برای یک اسکین آپلود می‌کند و
// در پوشه‌ی دیسکیِ ماندگار (cardsDir/<id>/) استخراج می‌کند تا بدونِ کامپایلِ
// دوباره از همان مسیرِ /cards/<id>/... سرو شود.
func (h *Handler) uploadDeck(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" || strings.ContainsAny(id, "/\\.") {
http.Error(w, "invalid skin id", http.StatusBadRequest)
return
}
if h.cardsDir == "" {
http.Error(w, "CARDS_DIR تنظیم نشده است", http.StatusInternalServerError)
return
}
if err := r.ParseMultipartForm(64 << 20); err != nil { // حداکثر ۶۴MB
http.Error(w, "bad upload", http.StatusBadRequest)
return
}
file, _, err := r.FormFile("deck")
if err != nil {
http.Error(w, "deck (zip) لازم است", http.StatusBadRequest)
return
}
defer file.Close()
buf, err := io.ReadAll(io.LimitReader(file, 64<<20))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
zr, err := zip.NewReader(strings.NewReader(string(buf)), int64(len(buf)))
if err != nil {
http.Error(w, "فایل zip معتبر نیست", http.StatusBadRequest)
return
}
dir := filepath.Join(h.cardsDir, id)
if err := os.MkdirAll(dir, 0o755); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
written := 0
for _, zf := range zr.File {
if zf.FileInfo().IsDir() {
continue
}
out, ok := validCardFile(zf.Name)
if !ok {
continue // فایل‌های نامربوط/ناقص نادیده گرفته می‌شوند
}
if err := extractZipEntry(zf, filepath.Join(dir, out)); err != nil {
http.Error(w, fmt.Sprintf("استخراج %s: %v", zf.Name, err), http.StatusInternalServerError)
return
}
written++
}
if written == 0 {
http.Error(w, "هیچ تصویرِ کارتِ معتبری در zip یافت نشد (مثل AS.png، 10H.png، back.jpg)", http.StatusBadRequest)
return
}
http.Redirect(w, r, "/admin/shop?saved=1", http.StatusSeeOther)
}
func extractZipEntry(zf *zip.File, dst string) error {
rc, err := zf.Open()
if err != nil {
return err
}
defer rc.Close()
f, err := os.Create(dst)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(f, io.LimitReader(rc, 16<<20)) // سقفِ هر فایل ۱۶MB
return err
}
// deleteItem یک ردیفِ کاتالوگ را حذف می‌کند.
func (h *Handler) deleteItem(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
@@ -218,6 +332,15 @@ func (h *Handler) deleteItem(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/shop?saved=1", http.StatusSeeOther)
}
// resetSeason امتیازِ رتبه‌ی همه را صفر کرده و فصلِ جدید را آغاز می‌کند.
func (h *Handler) resetSeason(w http.ResponseWriter, r *http.Request) {
if err := h.eco.ResetSeason(r.Context()); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/?saved=1", http.StatusSeeOther)
}
// --- کاربران ---
func (h *Handler) users(w http.ResponseWriter, r *http.Request) {
+9
View File
@@ -5,12 +5,21 @@
{{template "nav" .}}
<div class="wrap">
<h1>داشبورد</h1>
{{if .Saved}}<div class="saved">انجام شد ✓</div>{{end}}
<div class="stats">
<div class="stat">کاربران<b>{{.Users}}</b></div>
<div class="stat">خریدهای موفق<b>{{.Purchases}}</b></div>
<div class="stat">بازی‌های ثبت‌شده<b>{{.Games}}</b></div>
<div class="stat">مجموع سکه‌ی کاربران<b>{{.Coins}}</b></div>
<div class="stat">فصلِ رتبه‌بندی<b>{{.Season}}</b></div>
</div>
<h2>فصلِ رتبه‌بندی</h2>
<p style="color:#888">شروعِ فصلِ جدید، امتیازِ رتبه‌ی همه‌ی کاربران را صفر می‌کند.</p>
<form method="post" action="/admin/season/reset"
onsubmit="return confirm('فصلِ جدید؟ امتیازِ رتبه‌ی همه صفر می‌شود.');">
<button class="del">شروعِ فصلِ جدید (ریست رتبه‌ها)</button>
</form>
</div>
</body>
</html>
+14 -3
View File
@@ -65,8 +65,11 @@
</table>
<h2>اسکین کارت‌ها</h2>
<p style="color:#9aa;font-size:13px">
آپلودِ تصاویرِ هر اسکین: یک فایلِ <b>zip</b> شاملِ ۵۲ کارت با نامِ <code>AS.png</code>، <code>10H.png</code>، <code>KD.png</code> … و یک <code>back.jpg</code> (پشتِ کارت). نام‌ها بزرگ‌وکوچک مهم نیست.
</p>
<table>
<tr><th>شناسه</th><th>عنوان</th><th>قیمت (سکه)</th><th>ترتیب</th><th>فعال</th><th colspan="2"></th></tr>
<tr><th>شناسه</th><th>عنوان</th><th>قیمت (سکه)</th><th>ترتیب</th><th>فعال</th><th colspan="2"></th><th>تصاویر</th></tr>
{{range .Cards}}
<tr><form method="post" action="/admin/shop/card">
<td>{{.id}}<input type="hidden" name="id" value="{{.id}}"></td>
@@ -76,7 +79,12 @@
<td><input type="checkbox" name="enabled" {{if .enabled}}checked{{end}}></td>
<td><button>ذخیره</button></td>
<td><button formaction="/admin/shop/card/delete" formnovalidate class="del">حذف</button></td>
</form></tr>
</form>
<td><form method="post" action="/admin/shop/card/{{.id}}/upload" enctype="multipart/form-data" style="display:flex;gap:4px">
<input type="file" name="deck" accept=".zip" required>
<button>آپلود zip</button>
</form></td>
</tr>
{{end}}
<tr class="addrow"><form method="post" action="/admin/shop/card/add">
<td><input name="id" placeholder="شناسه" required></td>
@@ -143,8 +151,9 @@
</table>
<h2>انواع میز</h2>
<p style="color:#888">«امتیاز رتبه» اگر بزرگ‌تر از ۰ باشد، آن میز رتبه‌بندی است و به برنده امتیازِ رتبه می‌دهد.</p>
<table>
<tr><th>شناسه</th><th>عنوان</th><th>دست</th><th>ورودی</th><th>جایزه</th><th>XP</th><th>جام</th><th>ترتیب</th><th>فعال</th><th colspan="2"></th></tr>
<tr><th>شناسه</th><th>عنوان</th><th>دست</th><th>ورودی</th><th>جایزه</th><th>XP</th><th>جام</th><th>امتیاز رتبه</th><th>ترتیب</th><th>فعال</th><th colspan="2"></th></tr>
{{range .Tiers}}
<tr><form method="post" action="/admin/shop/tier">
<td>{{.id}}<input type="hidden" name="id" value="{{.id}}"></td>
@@ -154,6 +163,7 @@
<td><input name="prize" value="{{.prize}}"></td>
<td><input name="xp" value="{{.xp}}"></td>
<td><input name="trophy" value="{{.trophy}}"></td>
<td><input name="rank_reward" value="{{.rank_reward}}"></td>
<td><input name="sort" value="{{.sort}}"></td>
<td><input type="checkbox" name="enabled" {{if .enabled}}checked{{end}}></td>
<td><button>ذخیره</button></td>
@@ -168,6 +178,7 @@
<td><input name="prize" value="0"></td>
<td><input name="xp" value="0"></td>
<td><input name="trophy" value="0"></td>
<td><input name="rank_reward" value="0"></td>
<td><input name="sort" value="0"></td>
<td><input type="checkbox" name="enabled" checked></td>
<td colspan="2"><button>افزودن</button></td>
+2
View File
@@ -20,6 +20,7 @@ type Config struct {
AdminOTP string // کد ثابت ادمین
AdminUser string // یوزرنیمِ پنل ادمین (Basic Auth)
AdminPass string // پسوردِ پنل ادمین (Basic Auth)
CardsDir string // پوشه‌ی دیسکیِ اسکین‌های آپلودی (روی volume ماندگار)
}
// Load کانفیگ را از env با مقادیر پیش‌فرض معقول می‌خواند.
@@ -37,6 +38,7 @@ func Load() Config {
AdminOTP: env("ADMIN_OTP", ""),
AdminUser: env("ADMIN_PANEL_USER", "admin"),
AdminPass: env("ADMIN_PANEL_PASS", "admin"),
CardsDir: env("CARDS_DIR", "carddata"),
}
}
+25
View File
@@ -50,6 +50,7 @@ type VIPPackage struct {
}
// TableTier نوع میز: تعداد دست، ورودی، جایزه، XP و جام.
// RankReward > 0 یعنی میزِ رتبه‌بندی است (به برنده امتیازِ رتبه می‌دهد).
type TableTier struct {
ID string `json:"id"`
Title string `json:"title"`
@@ -58,6 +59,7 @@ type TableTier struct {
Prize int64 `json:"prize"`
XP int `json:"xp"`
Trophy int `json:"trophy"`
RankReward int `json:"rank_reward"`
}
// Catalog کل کاتالوگ فروشگاه (برای GET /api/shop). از دیتابیس بارگذاری می‌شود.
@@ -70,6 +72,29 @@ type Catalog struct {
TableTiers []TableTier `json:"table_tiers"`
}
// rankTiers آستانه‌ی امتیازِ هر نشانِ رتبه (از پایین به بالا).
var rankTiers = []struct {
Name string
Min int64
}{
{"bronze", 0},
{"silver", 500},
{"gold", 1500},
{"diamond", 3000},
{"king", 5000},
}
// RankTier نام و اندیسِ نشانِ رتبه را از امتیازِ رتبه برمی‌گرداند.
func RankTier(points int64) (name string, index int) {
name, index = rankTiers[0].Name, 0
for i, t := range rankTiers {
if points >= t.Min {
name, index = t.Name, i
}
}
return name, index
}
// LevelInfo سطح فعلی، XP درون‌سطح و XP لازم برای سطح بعد را از XP کل محاسبه می‌کند.
// نیاز هر سطح: ۵۰ × شماره سطح (مثلاً سطح ۸ ⇒ ۴۰۰).
func LevelInfo(totalXP int64) (level int, into int64, need int64) {
+2 -2
View File
@@ -83,13 +83,13 @@ func (s *Service) LoadCatalog(ctx context.Context) error {
rows.Close()
rows, err = s.db.QueryContext(ctx,
`SELECT id, title, hands, entry, prize, xp, trophy FROM table_tiers WHERE enabled = 1 ORDER BY sort`)
`SELECT id, title, hands, entry, prize, xp, trophy, rank_reward FROM table_tiers WHERE enabled = 1 ORDER BY sort`)
if err != nil {
return err
}
for rows.Next() {
var p TableTier
if err := rows.Scan(&p.ID, &p.Title, &p.Hands, &p.Entry, &p.Prize, &p.XP, &p.Trophy); err != nil {
if err := rows.Scan(&p.ID, &p.Title, &p.Hands, &p.Entry, &p.Prize, &p.XP, &p.Trophy, &p.RankReward); err != nil {
rows.Close()
return err
}
+6 -2
View File
@@ -56,6 +56,9 @@ type Profile struct {
VIP bool `json:"vip"`
VIPUntil *string `json:"vip_until"`
SelectedCard string `json:"selected_card"`
RankPoints int64 `json:"rank_points"`
RankTier string `json:"rank_tier"` // bronze..king
RankIndex int `json:"rank_index"` // ۰..۴
}
// GetProfile وضعیت اقتصادی کاربر را می‌خواند.
@@ -63,8 +66,8 @@ func (s *Service) GetProfile(ctx context.Context, userID int64) (*Profile, error
var p Profile
var vipUntil sql.NullString
err := s.db.QueryRowContext(ctx,
`SELECT coins, tickets, xp, trophies, vip_until, selected_card FROM users WHERE id = ?`, userID).
Scan(&p.Coins, &p.Tickets, &p.XP, &p.Trophies, &vipUntil, &p.SelectedCard)
`SELECT coins, tickets, xp, trophies, vip_until, selected_card, rank_points FROM users WHERE id = ?`, userID).
Scan(&p.Coins, &p.Tickets, &p.XP, &p.Trophies, &vipUntil, &p.SelectedCard, &p.RankPoints)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
@@ -72,6 +75,7 @@ func (s *Service) GetProfile(ctx context.Context, userID int64) (*Profile, error
return nil, err
}
p.Level, p.XPInto, p.XPNeed = LevelInfo(p.XP)
p.RankTier, p.RankIndex = RankTier(p.RankPoints)
if vipUntil.Valid {
p.VIPUntil = &vipUntil.String
p.VIP = parseTS(vipUntil.String).After(nowUTC())
+17
View File
@@ -176,6 +176,23 @@ func (h *Handler) TablesInfo(w http.ResponseWriter, r *http.Request) {
})
}
// Leaderboard — GET /api/leaderboard (جدولِ رتبه‌بندیِ فصل)
func (h *Handler) Leaderboard(w http.ResponseWriter, r *http.Request) {
if _, ok := uid(r); !ok {
httpx.Error(w, http.StatusUnauthorized, "unauthorized")
return
}
list, err := h.svc.Leaderboard(r.Context(), 50)
if err != nil {
httpx.Error(w, http.StatusInternalServerError, "server error")
return
}
httpx.JSON(w, http.StatusOK, map[string]any{
"season": h.svc.Season(r.Context()),
"entries": list,
})
}
// Daily — POST /api/rewards/daily
func (h *Handler) Daily(w http.ResponseWriter, r *http.Request) {
id, ok := uid(r)
+88
View File
@@ -0,0 +1,88 @@
package economy
import (
"context"
"database/sql"
)
// RankEntry یک ردیف از جدولِ رتبه‌بندی.
type RankEntry struct {
Rank int `json:"rank"`
Name string `json:"name"`
Avatar string `json:"avatar"`
RankPoints int64 `json:"rank_points"`
Tier string `json:"tier"`
}
// AwardRank امتیازِ رتبه را پس از یک بازیِ رتبه‌بندی به‌روزرسانی می‌کند.
// برنده +reward، بازنده reward/2 (کفِ صفر). برای میزِ غیرِرتبه‌بندی بی‌اثر است.
func (s *Service) AwardRank(ctx context.Context, userID int64, reward int, won bool) error {
if reward <= 0 {
return nil
}
delta := reward
if !won {
delta = -(reward / 2)
}
_, err := s.db.ExecContext(ctx,
`UPDATE users SET rank_points = MAX(0, rank_points + ?) WHERE id = ?`,
delta, userID)
return err
}
// Leaderboard فهرستِ برترین بازیکنان بر اساس امتیازِ رتبه را برمی‌گرداند.
func (s *Service) Leaderboard(ctx context.Context, limit int) ([]RankEntry, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
rows, err := s.db.QueryContext(ctx,
`SELECT first_name, avatar, rank_points FROM users
WHERE rank_points > 0 ORDER BY rank_points DESC LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]RankEntry, 0, limit)
rank := 0
for rows.Next() {
var name, avatar sql.NullString
var pts int64
if err := rows.Scan(&name, &avatar, &pts); err != nil {
return nil, err
}
rank++
tier, _ := RankTier(pts)
nm := name.String
if nm == "" {
nm = "بازیکن"
}
out = append(out, RankEntry{
Rank: rank, Name: nm, Avatar: avatar.String, RankPoints: pts, Tier: tier,
})
}
return out, rows.Err()
}
// ResetSeason امتیازِ رتبه‌ی همه‌ی کاربران را صفر کرده و شماره‌ی فصل را زیاد می‌کند.
func (s *Service) ResetSeason(ctx context.Context) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `UPDATE users SET rank_points = 0`); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`UPDATE app_settings SET value = value + 1 WHERE key = 'season'`); err != nil {
return err
}
return tx.Commit()
}
// Season شماره‌ی فصلِ جاری را برمی‌گرداند.
func (s *Service) Season(ctx context.Context) int {
var v int
_ = s.db.QueryRowContext(ctx, `SELECT value FROM app_settings WHERE key = 'season'`).Scan(&v)
return v
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 641 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 660 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 649 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 624 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 686 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Some files were not shown because too many files have changed in this diff Show More