Compare commits
13
Commits
ac6e8695b1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba660f156c | ||
|
|
3883d1b471 | ||
|
|
64bdf5fc3d | ||
|
|
ceb1c68174 | ||
|
|
fe0b63083c | ||
|
|
da6df0328f | ||
|
|
b5420bac21 | ||
|
|
525cd554ef | ||
|
|
e7b2e05428 | ||
|
|
e8396db45c | ||
|
|
e58c50d6e5 | ||
|
|
bcfaf35d73 | ||
|
|
a72cf582d0 |
@@ -0,0 +1,192 @@
|
||||
// Command loadtest یک استرستستِ واقعیِ WebSocket برای سرورِ حکم است: N بازیکنِ
|
||||
// مصنوعی را میسازد، هرکدام join_queue میکند و بازیِ کاملِ حکم را بهصورتِ خودکار
|
||||
// (انتخابِ حکم + بازیِ کارتِ قانونی) بازی میکند تا بارِ واقعیِ پخشِ وضعیت تولید شود.
|
||||
//
|
||||
// توکنها با همان JWT_SECRET سرور ساخته میشوند (بدونِ نیاز به OTP/SMS). کاربرها
|
||||
// باید در DB وجود داشته باشند و برای tierِ انتخابی سکهی کافی داشته باشند.
|
||||
//
|
||||
// مثال (روی نمونهی محلی/استیجینگ — نه روی پروداکشنِ واقعی):
|
||||
//
|
||||
// go run ./cmd/loadtest -url ws://localhost:8080 -n 200 -tier beginner \
|
||||
// -secret "$JWT_SECRET" -startid 1000 -ramp 50ms
|
||||
//
|
||||
// خروجی هر ثانیه: اتصالهای فعال، پیامهای وضعیت/ثانیه، حرکتها/ثانیه، خطاها.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
var (
|
||||
connected int64
|
||||
stateMsgs int64
|
||||
moves int64
|
||||
errs int64
|
||||
)
|
||||
|
||||
type state struct {
|
||||
Type string `json:"type"`
|
||||
Phase string `json:"phase"`
|
||||
YourSeat int `json:"your_seat"`
|
||||
Hakem int `json:"hakem"`
|
||||
Turn int `json:"turn"`
|
||||
TrickDone bool `json:"trick_done"`
|
||||
YourHand []string `json:"your_hand"`
|
||||
LeadSuit string `json:"lead_suit"`
|
||||
}
|
||||
|
||||
var suitWord = map[byte]string{'H': "hearts", 'S': "spades", 'D': "diamonds", 'C': "clubs"}
|
||||
var wordSuit = map[string]byte{"hearts": 'H', "spades": 'S', "diamonds": 'D', "clubs": 'C'}
|
||||
|
||||
func mintToken(secret string, uid int64, ttl time.Duration) string {
|
||||
claims := jwt.MapClaims{
|
||||
"sub": strconv.FormatInt(uid, 10),
|
||||
"abl": "play",
|
||||
"exp": time.Now().Add(ttl).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
s, _ := t.SignedString([]byte(secret))
|
||||
return s
|
||||
}
|
||||
|
||||
// pickCard یک کارتِ قانونی برمیگرداند (پیرویِ خال در صورتِ داشتن).
|
||||
func pickCard(hand []string, lead string) string {
|
||||
if lead != "" {
|
||||
want := wordSuit[lead]
|
||||
var same []string
|
||||
for _, c := range hand {
|
||||
if c[len(c)-1] == want {
|
||||
same = append(same, c)
|
||||
}
|
||||
}
|
||||
if len(same) > 0 {
|
||||
return same[rand.Intn(len(same))]
|
||||
}
|
||||
}
|
||||
return hand[rand.Intn(len(hand))]
|
||||
}
|
||||
|
||||
func runClient(wsURL, secret, tier string, uid int64, playDelay time.Duration, wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
tok := mintToken(secret, uid, time.Hour)
|
||||
u := wsURL + "/ws?token=" + tok
|
||||
c, _, err := websocket.DefaultDialer.Dial(u, http.Header{})
|
||||
if err != nil {
|
||||
atomic.AddInt64(&errs, 1)
|
||||
return
|
||||
}
|
||||
atomic.AddInt64(&connected, 1)
|
||||
defer func() { atomic.AddInt64(&connected, -1); c.Close() }()
|
||||
|
||||
_ = c.WriteJSON(map[string]any{"type": "join_queue", "tier": tier})
|
||||
|
||||
for {
|
||||
var raw map[string]any
|
||||
if err := c.ReadJSON(&raw); err != nil {
|
||||
atomic.AddInt64(&errs, 1)
|
||||
return
|
||||
}
|
||||
if raw["type"] != "state" {
|
||||
continue
|
||||
}
|
||||
atomic.AddInt64(&stateMsgs, 1)
|
||||
// دوباره بهصورتِ typed decode کن.
|
||||
var s state
|
||||
if b, ok := raw["your_hand"].([]any); ok {
|
||||
for _, x := range b {
|
||||
s.YourHand = append(s.YourHand, x.(string))
|
||||
}
|
||||
}
|
||||
s.Phase, _ = raw["phase"].(string)
|
||||
s.LeadSuit, _ = raw["lead_suit"].(string)
|
||||
s.YourSeat = intOf(raw["your_seat"])
|
||||
s.Hakem = intOf(raw["hakem"])
|
||||
s.Turn = intOf(raw["turn"])
|
||||
s.TrickDone, _ = raw["trick_done"].(bool)
|
||||
|
||||
if s.Turn != s.YourSeat || s.TrickDone {
|
||||
continue
|
||||
}
|
||||
time.Sleep(playDelay) // شبیهسازیِ زمانِ فکرِ انسان
|
||||
switch s.Phase {
|
||||
case "choose_trump":
|
||||
if s.Hakem == s.YourSeat {
|
||||
_ = c.WriteJSON(map[string]any{"type": "choose_trump", "suit": bestSuit(s.YourHand)})
|
||||
atomic.AddInt64(&moves, 1)
|
||||
}
|
||||
case "playing":
|
||||
if len(s.YourHand) > 0 {
|
||||
_ = c.WriteJSON(map[string]any{"type": "play_card", "card": pickCard(s.YourHand, s.LeadSuit)})
|
||||
atomic.AddInt64(&moves, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func bestSuit(hand []string) string {
|
||||
cnt := map[byte]int{}
|
||||
for _, c := range hand {
|
||||
cnt[c[len(c)-1]]++
|
||||
}
|
||||
best, n := byte('S'), -1
|
||||
for s, k := range cnt {
|
||||
if k > n {
|
||||
best, n = s, k
|
||||
}
|
||||
}
|
||||
return suitWord[best]
|
||||
}
|
||||
|
||||
func intOf(v any) int {
|
||||
if f, ok := v.(float64); ok {
|
||||
return int(f)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func main() {
|
||||
url := flag.String("url", "ws://localhost:8080", "ws base url (no /ws)")
|
||||
n := flag.Int("n", 100, "concurrent players")
|
||||
tier := flag.String("tier", "beginner", "table tier id")
|
||||
secret := flag.String("secret", "", "JWT_SECRET of the target server")
|
||||
startID := flag.Int64("startid", 1000, "first synthetic user id (must exist in DB w/ coins)")
|
||||
ramp := flag.Duration("ramp", 30*time.Millisecond, "delay between client starts")
|
||||
play := flag.Duration("play", 300*time.Millisecond, "think time before each move")
|
||||
flag.Parse()
|
||||
if *secret == "" {
|
||||
log.Fatal("-secret (JWT_SECRET) is required")
|
||||
}
|
||||
*url = strings.TrimRight(*url, "/")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
go func() {
|
||||
for range time.Tick(time.Second) {
|
||||
fmt.Printf("conns=%d state/s=%d moves/s=%d errs=%d\n",
|
||||
atomic.LoadInt64(&connected),
|
||||
atomic.SwapInt64(&stateMsgs, 0),
|
||||
atomic.SwapInt64(&moves, 0),
|
||||
atomic.LoadInt64(&errs))
|
||||
}
|
||||
}()
|
||||
|
||||
for i := 0; i < *n; i++ {
|
||||
wg.Add(1)
|
||||
go runClient(*url, *secret, *tier, *startID+int64(i), *play, &wg)
|
||||
time.Sleep(*ramp)
|
||||
}
|
||||
fmt.Printf("launched %d clients; Ctrl-C to stop\n", *n)
|
||||
wg.Wait()
|
||||
}
|
||||
+52
-4
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -27,9 +28,6 @@ func main() {
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, nil)))
|
||||
cfg := config.Load()
|
||||
|
||||
// تلسکوپ: بازرسِ درونبرنامهایِ درخواستها (رینگبافرِ حافظه، بدونِ وابستگی).
|
||||
scope := telescope.New(cfg.TelescopeSize)
|
||||
|
||||
st, err := store.Open(cfg.DBPath)
|
||||
if err != nil {
|
||||
slog.Error("open db", "err", err)
|
||||
@@ -44,6 +42,31 @@ func main() {
|
||||
jwt := auth.NewJWT(cfg.JWTSecret, cfg.JWTTTL)
|
||||
authH := auth.NewHandler(users, otp, kave, jwt, cfg.AdminMobile, cfg.AdminOTP)
|
||||
|
||||
// تلسکوپ: بازرسِ درونبرنامهایِ درخواستها. شناسهی کاربر را ارزان (فقط HMAC)
|
||||
// از توکن درمیآورد و شمارهی موبایل را در زمانِ نمایش (با DB) resolve میکند.
|
||||
scope := telescope.New(cfg.TelescopeSize,
|
||||
func(r *http.Request) int64 {
|
||||
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if tok == "" || tok == r.Header.Get("Authorization") {
|
||||
tok = r.URL.Query().Get("token")
|
||||
}
|
||||
if tok == "" {
|
||||
return 0
|
||||
}
|
||||
id, err := jwt.Verify(tok)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return id
|
||||
},
|
||||
func(id int64) string {
|
||||
if u, err := users.FindByID(context.Background(), id); err == nil {
|
||||
return u.Mobile
|
||||
}
|
||||
return ""
|
||||
},
|
||||
)
|
||||
|
||||
// اقتصاد و فروشگاه. تأییدِ خرید: مایکت (X-Access-Token) و کافهبازار (امضای RSA).
|
||||
// اگر کلیدها تنظیم نشده باشند، به تأییدکنندهی توسعه برمیگردیم (dev fallback).
|
||||
iap := economy.StoreRouter{DevFallback: true}
|
||||
@@ -67,6 +90,9 @@ func main() {
|
||||
slog.Info("IAP bazaar (RSA) configured")
|
||||
}
|
||||
eco := economy.New(st.DB, economy.DevAdVerifier{}, iap)
|
||||
eco.SetPublicURL(cfg.PublicURL) // برای ساختِ URL کاملِ تصاویر (آواتار/فرش)
|
||||
// آواتارهای «svgِ رستری» (PNG داخلِ svg) را به PNG واقعی تبدیل کن (یکبار، idempotent).
|
||||
admin.ConvertRasterAvatars(context.Background(), st.DB, cfg.AvatarsDir)
|
||||
if err := eco.LoadCatalog(context.Background()); err != nil {
|
||||
slog.Error("load catalog", "err", err)
|
||||
os.Exit(1)
|
||||
@@ -111,9 +137,11 @@ func main() {
|
||||
r.Handle("/cards/*", static.CardsHandler(cfg.CardsDir))
|
||||
// تصاویرِ فرش (بهجای میز) — از پنل ادمین آپلود میشوند.
|
||||
r.Handle("/carpets/*", static.CarpetsHandler(cfg.CarpetsDir))
|
||||
// تصاویرِ شخصیتِ آواتار — از پنل ادمین آپلود میشوند.
|
||||
r.Handle("/avatars/*", static.AvatarsHandler(cfg.AvatarsDir))
|
||||
|
||||
// پنل ادمین (HTML سرورساید، پشت Basic Auth)
|
||||
r.Mount("/admin", admin.New(st.DB, eco, cfg.CardsDir, cfg.CarpetsDir).Routes(cfg.AdminUser, cfg.AdminPass))
|
||||
r.Mount("/admin", admin.New(st.DB, eco, cfg.CardsDir, cfg.CarpetsDir, cfg.AvatarsDir).Routes(cfg.AdminUser, cfg.AdminPass))
|
||||
|
||||
// بازرسِ درخواستها (Telescope-lite) — پشتِ همان Basic Auth ادمین.
|
||||
r.Group(func(r chi.Router) {
|
||||
@@ -146,12 +174,32 @@ func main() {
|
||||
r.Get("/carpets", ecoH.Carpets)
|
||||
r.Post("/shop/buy-carpet", ecoH.BuyCarpet)
|
||||
r.Post("/shop/select-carpet", ecoH.SelectCarpet)
|
||||
r.Get("/frames", ecoH.Frames)
|
||||
r.Post("/shop/buy-frame", ecoH.BuyFrame)
|
||||
r.Post("/shop/select-frame", ecoH.SelectFrame)
|
||||
r.Get("/avatars", ecoH.Avatars)
|
||||
r.Post("/shop/buy-avatar", ecoH.BuyAvatar)
|
||||
r.Post("/shop/select-avatar", ecoH.SelectAvatar)
|
||||
r.Post("/shop/purchase", ecoH.Purchase)
|
||||
r.Post("/rewards/daily", ecoH.Daily)
|
||||
r.Post("/rewards/ad", ecoH.AdReward)
|
||||
|
||||
// تورنومنتها
|
||||
r.Get("/tournaments", ecoH.Tournaments)
|
||||
r.Get("/tournaments/standings", ecoH.TournamentStandings)
|
||||
r.Post("/tournaments/join", ecoH.JoinTournament)
|
||||
})
|
||||
})
|
||||
|
||||
// تسویهی خودکارِ تورنومنتهای پایانیافته (هر دقیقه).
|
||||
go func() {
|
||||
t := time.NewTicker(time.Minute)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
eco.SettleTournaments(context.Background())
|
||||
}
|
||||
}()
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
Handler: r,
|
||||
|
||||
@@ -68,18 +68,21 @@ func (g gameSettler) RecordCut(userID int64) {
|
||||
g.eco.RecordCut(context.Background(), userID)
|
||||
}
|
||||
|
||||
// RecordGameResult آمار پایان بازی (برد/باخت) را ثبت میکند.
|
||||
// RecordGameResult آمار پایان بازی (برد/باخت) را ثبت میکند و امتیازِ تورنومنت
|
||||
// را (برای تورنومنتهای فعالی که کاربر عضوشان است) بهروزرسانی میکند.
|
||||
func (g gameSettler) RecordGameResult(userID int64, won bool) {
|
||||
g.eco.RecordGameResult(context.Background(), userID, won)
|
||||
g.eco.AddTournamentPoints(context.Background(), userID, won)
|
||||
}
|
||||
|
||||
// PlayerProfile سکه، نشانِ رتبه و آواتارِ بازیکن را برای نمایش سرِ میز برمیگرداند.
|
||||
func (g gameSettler) PlayerProfile(userID int64) (int64, string, string) {
|
||||
// PlayerProfile سکه، نشانِ رتبه، آواتار، قاب و شخصیتِ آواتارِ بازیکن را برای نمایشِ
|
||||
// سرِ میز برمیگرداند.
|
||||
func (g gameSettler) PlayerProfile(userID int64) (int64, string, string, string, string) {
|
||||
p, err := g.eco.GetProfile(context.Background(), userID)
|
||||
if err != nil || p == nil {
|
||||
return 0, "", ""
|
||||
return 0, "", "", "", ""
|
||||
}
|
||||
return p.Coins, p.RankTier, p.Avatar
|
||||
return p.Coins, p.RankTier, p.Avatar, p.SelectedFrame, p.SelectedAvatarImg
|
||||
}
|
||||
|
||||
// ChargeTicket یک بلیت برای بُرِ مجدد مصرف میکند (خطا اگر بلیت کافی نباشد).
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
+129
-3
@@ -33,11 +33,12 @@ type Handler struct {
|
||||
tpl *template.Template
|
||||
cardsDir string // پوشهی دیسکیِ آپلودِ اسکینها (همان مسیری که static از آن سرو میکند)
|
||||
carpetsDir string // پوشهی دیسکیِ آپلودِ تصاویرِ فرش
|
||||
avatarsDir string // پوشهی دیسکیِ آپلودِ تصاویرِ شخصیتِ آواتار
|
||||
}
|
||||
|
||||
func New(db *sql.DB, eco *economy.Service, cardsDir, carpetsDir string) *Handler {
|
||||
func New(db *sql.DB, eco *economy.Service, cardsDir, carpetsDir, avatarsDir string) *Handler {
|
||||
tpl := template.Must(template.ParseFS(tplFS, "templates/*.html"))
|
||||
return &Handler{db: db, eco: eco, tpl: tpl, cardsDir: cardsDir, carpetsDir: carpetsDir}
|
||||
return &Handler{db: db, eco: eco, tpl: tpl, cardsDir: cardsDir, carpetsDir: carpetsDir, avatarsDir: avatarsDir}
|
||||
}
|
||||
|
||||
// Routes زیرروترِ /admin را با Basic Auth برمیگرداند.
|
||||
@@ -64,6 +65,11 @@ func (h *Handler) Routes(user, pass string) http.Handler {
|
||||
r.Post("/chat/message/add", h.chatMessageAdd)
|
||||
r.Post("/chat/message", h.chatMessageUpdate)
|
||||
r.Post("/chat/message/delete", h.chatMessageDelete)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -532,6 +538,61 @@ func (h *Handler) carpetUpload(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/admin/carpets?saved=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// avatarUpload یک تصویرِ png یا svg را برای یک شخصیتِ آواتار آپلود و با نامِ
|
||||
// <id>.<ext> ذخیره میکند و img_ext را در دیتابیس بهروزرسانی میکند. نوعِ فایل از
|
||||
// پسوندِ نامِ آپلودشده تشخیص داده میشود (svg → svg، وگرنه png). png برای شفافیت.
|
||||
func (h *Handler) avatarUpload(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if id == "" || strings.ContainsAny(id, "/\\.") {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if h.avatarsDir == "" {
|
||||
http.Error(w, "AVATARS_DIR تنظیم نشده است", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(16 << 20); err != nil {
|
||||
http.Error(w, "bad upload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
file, hdr, err := r.FormFile("image")
|
||||
if err != nil {
|
||||
http.Error(w, "image لازم است", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
buf, err := io.ReadAll(io.LimitReader(file, 16<<20))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
ext := "png"
|
||||
if strings.HasSuffix(strings.ToLower(hdr.Filename), ".svg") {
|
||||
ext = "svg"
|
||||
// «SVGِ رستری» (PNG داخلِ svg) را به PNG واقعی تبدیل کن تا اپ رندرش کند.
|
||||
if raw, rext, ok := extractEmbeddedRaster(buf); ok {
|
||||
buf, ext = raw, rext
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(h.avatarsDir, 0o755); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// نسخههای قبلی با پسوندِ دیگر را پاک کن تا چند نسخه نماند.
|
||||
for _, e := range []string{"png", "jpg", "svg"} {
|
||||
if e != ext {
|
||||
_ = os.Remove(filepath.Join(h.avatarsDir, id+"."+e))
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(h.avatarsDir, id+"."+ext), buf, 0o644); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, _ = h.db.ExecContext(r.Context(), `UPDATE avatars SET img_ext = ? WHERE id = ?`, ext, id)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}
|
||||
|
||||
// --- مدیریتِ بستههای چت و پیامها ---
|
||||
|
||||
func (h *Handler) chatAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -658,7 +719,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
|
||||
}
|
||||
@@ -681,3 +747,63 @@ func (h *Handler) rows(ctx context.Context, query string, cols ...string) []map[
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- مدیریتِ تورنومنتها ---
|
||||
|
||||
func (h *Handler) tournamentsAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
list, _ := h.eco.AdminListTournaments(r.Context())
|
||||
h.render(w, "tournaments.html", map[string]any{
|
||||
"Nav": "tournaments",
|
||||
"Tournaments": list,
|
||||
"Saved": r.URL.Query().Get("saved") == "1",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) tournamentAdd(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
num := func(k string) int64 { v, _ := strconv.ParseInt(r.FormValue(k), 10, 64); return v }
|
||||
// جوایز: مقادیرِ سکه با کاما جدا شده ("۵۰۰۰,۳۰۰۰,۱۰۰۰").
|
||||
var prizes []int64
|
||||
for _, p := range strings.Split(r.FormValue("prizes"), ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if v, err := strconv.ParseInt(p, 10, 64); err == nil {
|
||||
prizes = append(prizes, v)
|
||||
}
|
||||
}
|
||||
// ورودیِ datetime-local به وقتِ تهران (UTC+3:30) تفسیر و به UTC ذخیره میشود.
|
||||
iran := time.FixedZone("IRST", 12600)
|
||||
parseDT := func(k string) time.Time {
|
||||
t, err := time.ParseInLocation("2006-01-02T15:04", r.FormValue(k), iran)
|
||||
if err != nil {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
return t.UTC()
|
||||
}
|
||||
if r.FormValue("title") == "" {
|
||||
http.Error(w, "عنوان لازم است", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_, err := h.eco.CreateTournament(r.Context(), economy.Tournament{
|
||||
Title: r.FormValue("title"),
|
||||
Description: r.FormValue("description"),
|
||||
EntryFee: num("entry_fee"),
|
||||
Prizes: prizes,
|
||||
StartsAt: parseDT("starts_at"),
|
||||
EndsAt: parseDT("ends_at"),
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/tournaments?saved=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handler) tournamentDelete(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64)
|
||||
_ = h.eco.DeleteTournament(r.Context(), id)
|
||||
http.Redirect(w, r, "/admin/tournaments?saved=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,638 @@
|
||||
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("/purchases", h.apiPurchases)
|
||||
|
||||
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("/avatars", h.apiAvatars)
|
||||
r.Post("/avatars", h.apiAvatarUpsert)
|
||||
r.Delete("/avatars", h.apiAvatarDelete)
|
||||
r.Post("/avatars/{id}/upload", h.avatarUpload) // multipart (تصویرِ png)
|
||||
|
||||
r.Get("/rank-tiers", h.apiRankTiers)
|
||||
r.Post("/rank-tiers", h.apiRankTierUpsert)
|
||||
|
||||
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
|
||||
}
|
||||
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),
|
||||
"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),
|
||||
})
|
||||
}
|
||||
|
||||
// --- کاتالوگِ فروشگاه ---
|
||||
|
||||
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 flexInt `json:"user_id"`
|
||||
Amount flexInt `json:"amount"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || b.UserID == 0 {
|
||||
httpx.Error(w, http.StatusBadRequest, "بدنهی نامعتبر")
|
||||
return
|
||||
}
|
||||
if err := h.eco.Adjust(r.Context(), b.UserID.i(), economy.CurrencyCoin, b.Amount.i(), "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 flexInt `json:"user_id"`
|
||||
Kind string `json:"kind"` // coin | ticket | vip
|
||||
Amount flexInt `json:"amount"`
|
||||
VIPDays flexInt `json:"vip_days"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || b.UserID == 0 {
|
||||
httpx.Error(w, http.StatusBadRequest, "بدنهی نامعتبر")
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
var err error
|
||||
switch b.Kind {
|
||||
case "coin":
|
||||
err = h.eco.Adjust(ctx, b.UserID.i(), economy.CurrencyCoin, b.Amount.i(), "admin_grant", "panel")
|
||||
case "ticket":
|
||||
err = h.eco.Adjust(ctx, b.UserID.i(), economy.CurrencyTicket, b.Amount.i(), "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.i()), b.UserID.i())
|
||||
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 flexInt `json:"price_coins"`
|
||||
VIP flexBool `json:"vip"`
|
||||
Sort flexInt `json:"sort"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
|
||||
httpx.Error(w, http.StatusBadRequest, "بدنهی نامعتبر")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(b.ID) == "" {
|
||||
httpx.Error(w, http.StatusBadRequest, "شناسه (id) لازم است")
|
||||
return
|
||||
}
|
||||
if strings.ContainsAny(b.ID, `/\.`) {
|
||||
httpx.Error(w, http.StatusBadRequest, "شناسه نباید شاملِ نقطه، / یا \\ باشد")
|
||||
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.i(), vip, b.Sort.i()); 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) apiAvatars(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.JSON(w, http.StatusOK, map[string]any{
|
||||
"avatars": h.rows(r.Context(),
|
||||
`SELECT id,title,price_coins,vip,sort,enabled,img_ext FROM avatars ORDER BY sort`,
|
||||
"id", "title", "price_coins", "vip", "sort", "enabled", "img_ext"),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) apiAvatarUpsert(w http.ResponseWriter, r *http.Request) {
|
||||
var b struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PriceCoins flexInt `json:"price_coins"`
|
||||
VIP flexBool `json:"vip"`
|
||||
Sort flexInt `json:"sort"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
|
||||
httpx.Error(w, http.StatusBadRequest, "بدنهی نامعتبر")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(b.ID) == "" {
|
||||
httpx.Error(w, http.StatusBadRequest, "شناسه (id) لازم است")
|
||||
return
|
||||
}
|
||||
if strings.ContainsAny(b.ID, `/\.`) {
|
||||
httpx.Error(w, http.StatusBadRequest, "شناسه نباید شاملِ نقطه، / یا \\ باشد")
|
||||
return
|
||||
}
|
||||
vip := 0
|
||||
if b.VIP {
|
||||
vip = 1
|
||||
}
|
||||
// ON CONFLICT بهجای REPLACE تا img_ext (نوعِ فایلِ آپلودشده) هنگامِ ویرایش پاک نشود.
|
||||
if _, err := h.db.ExecContext(r.Context(),
|
||||
`INSERT INTO avatars (id,title,price_coins,vip,sort,enabled) VALUES (?,?,?,?,?,1)
|
||||
ON CONFLICT(id) DO UPDATE SET title=excluded.title, price_coins=excluded.price_coins,
|
||||
vip=excluded.vip, sort=excluded.sort, enabled=1`,
|
||||
b.ID, b.Title, b.PriceCoins.i(), vip, b.Sort.i()); err != nil {
|
||||
httpx.Error(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (h *Handler) apiAvatarDelete(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = h.db.ExecContext(r.Context(), `DELETE FROM avatars 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,c1,c2,sort,enabled FROM avatar_frames ORDER BY sort`,
|
||||
"id", "title", "price_coins", "vip", "c1", "c2", "sort", "enabled"),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) apiFrameUpsert(w http.ResponseWriter, r *http.Request) {
|
||||
var b struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PriceCoins flexInt `json:"price_coins"`
|
||||
VIP flexBool `json:"vip"`
|
||||
C1 string `json:"c1"`
|
||||
C2 string `json:"c2"`
|
||||
Sort flexInt `json:"sort"`
|
||||
Enabled *flexBool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || strings.TrimSpace(b.ID) == "" {
|
||||
httpx.Error(w, http.StatusBadRequest, "شناسه (id) لازم است")
|
||||
return
|
||||
}
|
||||
vip := 0
|
||||
if b.VIP {
|
||||
vip = 1
|
||||
}
|
||||
enabled := 1
|
||||
if b.Enabled != nil && !*b.Enabled {
|
||||
enabled = 0
|
||||
}
|
||||
// رنگها را نرمال کن (حذفِ # و فاصله).
|
||||
clean := func(s string) string { return strings.TrimPrefix(strings.TrimSpace(s), "#") }
|
||||
if _, err := h.db.ExecContext(r.Context(),
|
||||
`INSERT OR REPLACE INTO avatar_frames (id,title,price_coins,vip,c1,c2,sort,enabled) VALUES (?,?,?,?,?,?,?,?)`,
|
||||
b.ID, b.Title, b.PriceCoins.i(), vip, clean(b.C1), clean(b.C2), b.Sort.i(), 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) apiRankTiers(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.JSON(w, http.StatusOK, map[string]any{
|
||||
"rank_tiers": h.rows(r.Context(),
|
||||
`SELECT id,label,c1,c2,sort FROM rank_tiers ORDER BY sort`,
|
||||
"id", "label", "c1", "c2", "sort"),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) apiRankTierUpsert(w http.ResponseWriter, r *http.Request) {
|
||||
var b struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
C1 string `json:"c1"`
|
||||
C2 string `json:"c2"`
|
||||
Sort flexInt `json:"sort"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || strings.TrimSpace(b.ID) == "" {
|
||||
httpx.Error(w, http.StatusBadRequest, "شناسه (id) لازم است")
|
||||
return
|
||||
}
|
||||
clean := func(s string) string { return strings.TrimPrefix(strings.TrimSpace(s), "#") }
|
||||
if _, err := h.db.ExecContext(r.Context(),
|
||||
`INSERT OR REPLACE INTO rank_tiers (id,label,c1,c2,sort) VALUES (?,?,?,?,?)`,
|
||||
b.ID, b.Label, clean(b.C1), clean(b.C2), b.Sort.i()); err != nil {
|
||||
httpx.Error(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
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 flexInt `json:"price_coins"`
|
||||
VIP flexBool `json:"vip"`
|
||||
Sort flexInt `json:"sort"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || strings.TrimSpace(b.ID) == "" {
|
||||
httpx.Error(w, http.StatusBadRequest, "شناسه (id) لازم است")
|
||||
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.i(), vip, b.Sort.i()); 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 flexInt `json:"sort"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || b.PackID == "" || b.Body == "" {
|
||||
httpx.Error(w, http.StatusBadRequest, "بسته و متن لازم است")
|
||||
return
|
||||
}
|
||||
if _, err := h.db.ExecContext(r.Context(),
|
||||
`INSERT INTO chat_messages (pack_id,body,sort) VALUES (?,?,?)`, b.PackID, b.Body, b.Sort.i()); 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 flexInt `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
EntryFee flexInt `json:"entry_fee"`
|
||||
Prizes []flexInt `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()
|
||||
}
|
||||
prizes := make([]int64, len(b.Prizes))
|
||||
for i, p := range b.Prizes {
|
||||
prizes[i] = p.i()
|
||||
}
|
||||
return economy.Tournament{
|
||||
Title: b.Title, Description: b.Description, EntryFee: b.EntryFee.i(),
|
||||
Prizes: 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.i(), 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
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// برخی ابزارها (مثلِ خروجیِ AI) یک PNG را داخلِ یک <svg> با base64 قرار میدهند.
|
||||
// flutter_svg نمیتواند این «SVGِ رستری» را رندر کند، پس آن را به PNG واقعی تبدیل
|
||||
// میکنیم.
|
||||
var rasterInSVG = regexp.MustCompile(`data:image/(png|jpe?g);base64,([A-Za-z0-9+/=\s]+)`)
|
||||
|
||||
// extractEmbeddedRaster اگر محتوای SVG صرفاً یک تصویرِ رستریِ base64 باشد، آن را
|
||||
// استخراج میکند (بایتهای خام + پسوند). برای SVGهای واقعیِ برداری false برمیگرداند.
|
||||
func extractEmbeddedRaster(svg []byte) (data []byte, ext string, ok bool) {
|
||||
m := rasterInSVG.FindSubmatch(svg)
|
||||
if m == nil {
|
||||
return nil, "", false
|
||||
}
|
||||
ext = "png"
|
||||
if strings.HasPrefix(string(m[1]), "jp") {
|
||||
ext = "jpg"
|
||||
}
|
||||
b64 := regexp.MustCompile(`\s`).ReplaceAll(m[2], nil)
|
||||
raw, err := base64.StdEncoding.DecodeString(string(b64))
|
||||
if err != nil || len(raw) == 0 {
|
||||
return nil, "", false
|
||||
}
|
||||
return raw, ext, true
|
||||
}
|
||||
|
||||
// convertRasterAvatars فایلهای آواتارِ svg را که در واقع رستر هستند به png/jpg
|
||||
// تبدیل میکند و img_ext را در DB بهروزرسانی میکند. یکبار در startup اجرا میشود
|
||||
// (idempotent). برای رفعِ آواتارهایی که قبلاً بهصورتِ «svgِ رستری» آپلود شدهاند.
|
||||
func convertRasterAvatars(ctx context.Context, db *sql.DB, dir string) {
|
||||
if dir == "" {
|
||||
return
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, `SELECT id FROM avatars WHERE img_ext = 'svg'`)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if rows.Scan(&id) == nil {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, id := range ids {
|
||||
svgPath := filepath.Join(dir, id+".svg")
|
||||
content, err := os.ReadFile(svgPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
raw, ext, ok := extractEmbeddedRaster(content)
|
||||
if !ok {
|
||||
continue // SVGِ واقعیِ برداری — دست نزن.
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, id+"."+ext), raw, 0o644); err != nil {
|
||||
continue
|
||||
}
|
||||
_ = os.Remove(svgPath)
|
||||
_, _ = db.ExecContext(ctx, `UPDATE avatars SET img_ext = ? WHERE id = ?`, ext, id)
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertRasterAvatars نسخهی عمومی برای فراخوانی از main در startup.
|
||||
func ConvertRasterAvatars(ctx context.Context, db *sql.DB, dir string) {
|
||||
convertRasterAvatars(ctx, db, dir)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// flexBool یک bool است که هنگامِ decode از JSON، مقادیرِ عددی/رشتهای را هم میپذیرد
|
||||
// (true/false، 0/1، "1"/"true"). دلیل: SQLite بولینها را int برمیگرداند و پنل
|
||||
// همان مقدار را دوباره میفرستد؛ decodeِ سختگیرِ Go عدد را در bool نمیپذیرد → 400.
|
||||
type flexBool bool
|
||||
|
||||
func (f *flexBool) UnmarshalJSON(b []byte) error {
|
||||
s := strings.Trim(strings.TrimSpace(string(b)), `"`)
|
||||
*f = s == "1" || s == "true" || s == "1.0"
|
||||
return nil
|
||||
}
|
||||
|
||||
// flexInt یک int64 است که از عدد، رشتهی عددی، خالی یا null هم decode میشود
|
||||
// (تا ورودیهای متنیِ عددی مثلِ "2000" باعثِ 400 نشوند).
|
||||
type flexInt int64
|
||||
|
||||
func (f *flexInt) UnmarshalJSON(b []byte) error {
|
||||
s := strings.Trim(strings.TrimSpace(string(b)), `"`)
|
||||
if s == "" || s == "null" {
|
||||
*f = 0
|
||||
return nil
|
||||
}
|
||||
v, err := strconv.ParseFloat(s, 64) // "2000" و "2000.0" هر دو
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*f = flexInt(int64(v))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f flexInt) i() int64 { return int64(f) }
|
||||
@@ -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
|
||||
}
|
||||
@@ -35,6 +35,7 @@
|
||||
<a href="/admin/shop" class="{{if eq .Nav "shop"}}active{{end}}">فروشگاه</a>
|
||||
<a href="/admin/carpets" class="{{if eq .Nav "carpets"}}active{{end}}">فرشها</a>
|
||||
<a href="/admin/chat" class="{{if eq .Nav "chat"}}active{{end}}">چت</a>
|
||||
<a href="/admin/tournaments" class="{{if eq .Nav "tournaments"}}active{{end}}">تورنومنت</a>
|
||||
<a href="/admin/users" class="{{if eq .Nav "users"}}active{{end}}">کاربران</a>
|
||||
<a href="/admin/telescope">تلسکوپ</a>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<!doctype html>
|
||||
<html dir="rtl" lang="fa">
|
||||
{{template "head" .}}
|
||||
<body>
|
||||
{{template "nav" .}}
|
||||
<div class="wrap">
|
||||
<h1>تورنومنتها</h1>
|
||||
{{if .Saved}}<div class="saved">انجام شد ✓</div>{{end}}
|
||||
|
||||
<p style="color:#9aa;font-size:13px">
|
||||
تورنومنتِ امتیازی: بازیکنان با پرداختِ ورودی ثبتنام میکنند و در بازهی زمانی با
|
||||
بردنِ بازی امتیاز میگیرند (برد ۱۰۰، شرکت ۲۵). در پایان، نفراتِ برتر بهترتیبِ رتبه
|
||||
جایزهی سکه میگیرند. زمانها به وقتِ <b>تهران</b> است (برای «فعال»شدنِ فوری،
|
||||
زمانِ شروع را کمی قبل از الان بگذارید). جوایز را با کاما جدا کنید
|
||||
(نفرِ اول، دوم، …)، مثلاً <code>5000,3000,1000</code>.
|
||||
</p>
|
||||
|
||||
<table>
|
||||
<tr><th>#</th><th>عنوان</th><th>ورودی</th><th>جوایز</th><th>وضعیت</th><th>بازیکنان</th><th>شروع (تهران)</th><th>پایان (تهران)</th><th></th></tr>
|
||||
{{range .Tournaments}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td>{{.Title}}</td>
|
||||
<td>{{.EntryFee}}</td>
|
||||
<td>{{range .Prizes}}{{.}} {{end}}</td>
|
||||
<td>{{if eq .Status "active"}}<span style="color:#4ade80">فعال</span>{{else if eq .Status "upcoming"}}<span style="color:#fbbf24">بهزودی</span>{{else}}<span style="color:#888">پایانیافته</span>{{end}}</td>
|
||||
<td>{{.Players}}</td>
|
||||
<td style="direction:ltr;font-size:12px">{{.StartsLocal}}</td>
|
||||
<td style="direction:ltr;font-size:12px">{{.EndsLocal}}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/tournaments/delete" onsubmit="return confirm('حذفِ تورنومنت؟')">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button class="del">حذف</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
|
||||
<h2 style="margin-top:24px">افزودنِ تورنومنت</h2>
|
||||
<form method="post" action="/admin/tournaments/add" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;max-width:640px">
|
||||
<label>عنوان<input name="title" required></label>
|
||||
<label>ورودی (سکه)<input name="entry_fee" value="0"></label>
|
||||
<label style="grid-column:1/3">توضیح<input name="description" placeholder="مثلاً: تورنومنتِ شبانه"></label>
|
||||
<label style="grid-column:1/3">جوایز (سکه، با کاما)<input name="prizes" placeholder="5000,3000,1000"></label>
|
||||
<label>شروع (تهران)<input type="datetime-local" name="starts_at" required></label>
|
||||
<label>پایان (تهران)<input type="datetime-local" name="ends_at" required></label>
|
||||
<button style="grid-column:1/3">ساختِ تورنومنت</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -23,6 +23,8 @@ type Config struct {
|
||||
AdminPass string // پسوردِ پنل ادمین (Basic Auth)
|
||||
CardsDir string // پوشهی دیسکیِ اسکینهای آپلودی (روی volume ماندگار)
|
||||
CarpetsDir string // پوشهی دیسکیِ تصاویرِ فرش (روی volume ماندگار)
|
||||
AvatarsDir string // پوشهی دیسکیِ تصاویرِ شخصیتِ آواتار (روی volume ماندگار)
|
||||
PublicURL string // مبدأِ عمومیِ سرور (برای ساختِ URL کاملِ تصاویر مثلِ آواتار)
|
||||
TelescopeSize int // تعدادِ درخواستی که بازرسِ حافظه نگه میدارد
|
||||
// خرید درونبرنامهای (IAP)
|
||||
IAPPackage string // نامِ بستهی اپ (برای مایکت/کافه)
|
||||
@@ -48,6 +50,8 @@ func Load() Config {
|
||||
AdminPass: env("ADMIN_PANEL_PASS", "admin"),
|
||||
CardsDir: env("CARDS_DIR", "carddata"),
|
||||
CarpetsDir: env("CARPETS_DIR", "carpetdata"),
|
||||
AvatarsDir: env("AVATARS_DIR", "avatardata"),
|
||||
PublicURL: strings.TrimRight(env("PUBLIC_URL", "https://hakem.approagency.ir"), "/"),
|
||||
TelescopeSize: envInt("TELESCOPE_SIZE", 200),
|
||||
IAPPackage: env("IAP_PACKAGE", ""),
|
||||
MyketAccessToken: env("MYKET_ACCESS_TOKEN", ""),
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package economy
|
||||
|
||||
import "context"
|
||||
|
||||
// Avatar یک شخصیتِ آواتار (کازمتیک تصویری) که در کلِ بازی دیده میشود. تصویرِ آن
|
||||
// از /avatars/<id>.png سرو میشود. Owned یعنی قابلِ انتخاب.
|
||||
type Avatar struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PriceCoins int64 `json:"price_coins"`
|
||||
VIP bool `json:"vip"`
|
||||
Img string `json:"img"` // URL کاملِ تصویرِ شخصیت (URL-safe)
|
||||
Owned bool `json:"owned"`
|
||||
}
|
||||
|
||||
// Avatars آواتارهای فعال را با وضعیتِ مالکیتِ کاربر برمیگرداند.
|
||||
func (s *Service) Avatars(ctx context.Context, userID int64) ([]Avatar, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, title, price_coins, vip, img_ext FROM avatars WHERE enabled = 1 ORDER BY sort`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var avatars []Avatar
|
||||
for rows.Next() {
|
||||
var a Avatar
|
||||
var vip int
|
||||
var ext string
|
||||
if err := rows.Scan(&a.ID, &a.Title, &a.PriceCoins, &vip, &ext); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.VIP = vip == 1
|
||||
a.Img = s.imgURL("avatars", a.ID+"."+ext) // URL کاملِ URL-safe
|
||||
avatars = append(avatars, a)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
owned, err := s.ownedAvatars(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
isVIP := s.isVIP(ctx, userID)
|
||||
for i := range avatars {
|
||||
a := &avatars[i]
|
||||
a.Owned = (a.PriceCoins == 0 && !a.VIP) || (a.VIP && isVIP) || owned[a.ID]
|
||||
}
|
||||
return avatars, nil
|
||||
}
|
||||
|
||||
func (s *Service) ownedAvatars(ctx context.Context, userID int64) (map[string]bool, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT avatar_id FROM user_avatars WHERE user_id = ?`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
owned := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owned[id] = true
|
||||
}
|
||||
return owned, rows.Err()
|
||||
}
|
||||
|
||||
// BuyAvatar یک شخصیتِ آواتار را با سکه باز میکند.
|
||||
func (s *Service) BuyAvatar(ctx context.Context, userID int64, avatarID string) error {
|
||||
var price int64
|
||||
var vip, enabled int
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT price_coins, vip, enabled FROM avatars WHERE id = ?`, avatarID).
|
||||
Scan(&price, &vip, &enabled)
|
||||
if err != nil || enabled != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var exists int
|
||||
_ = tx.QueryRowContext(ctx,
|
||||
`SELECT 1 FROM user_avatars WHERE user_id = ? AND avatar_id = ?`, userID, avatarID).Scan(&exists)
|
||||
if exists == 1 {
|
||||
return ErrAlreadyOwned
|
||||
}
|
||||
if price > 0 {
|
||||
if err := adjustTx(ctx, tx, userID, CurrencyCoin, -price, "buy_avatar", avatarID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO user_avatars (user_id, avatar_id) VALUES (?, ?)`, userID, avatarID); err != nil {
|
||||
return err
|
||||
}
|
||||
// خرید = مالکیت + انتخابِ خودکار.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE users SET selected_avatar = ? WHERE id = ?`, avatarID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// SelectAvatar شخصیتِ انتخابیِ کاربر را تنظیم میکند (خالی ⇒ بازگشت به آواتارِ
|
||||
// تصادفی؛ وگرنه باید مالکش باشد یا رایگان/VIP).
|
||||
func (s *Service) SelectAvatar(ctx context.Context, userID int64, avatarID string) error {
|
||||
if avatarID == "" {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE users SET selected_avatar = '' WHERE id = ?`, userID)
|
||||
return err
|
||||
}
|
||||
avatars, err := s.Avatars(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, a := range avatars {
|
||||
if a.ID == avatarID {
|
||||
if !a.Owned {
|
||||
return ErrNotOwned
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE users SET selected_avatar = ? WHERE id = ?`, avatarID, userID)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return ErrNotFound
|
||||
}
|
||||
@@ -8,6 +8,7 @@ type Carpet struct {
|
||||
Title string `json:"title"`
|
||||
PriceCoins int64 `json:"price_coins"`
|
||||
VIP bool `json:"vip"`
|
||||
Img string `json:"img"` // URL کاملِ تصویرِ فرش (خالی برای classic که تصویری ندارد)
|
||||
Owned bool `json:"owned"`
|
||||
}
|
||||
|
||||
@@ -28,6 +29,9 @@ func (s *Service) Carpets(ctx context.Context, userID int64) ([]Carpet, error) {
|
||||
return nil, err
|
||||
}
|
||||
c.VIP = vip == 1
|
||||
if c.ID != "classic" {
|
||||
c.Img = s.imgURL("carpets", c.ID+".jpg")
|
||||
}
|
||||
carpets = append(carpets, c)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -95,6 +99,11 @@ func (s *Service) BuyCarpet(ctx context.Context, userID int64, carpetID string)
|
||||
`INSERT INTO user_carpets (user_id, carpet_id) VALUES (?, ?)`, userID, carpetID); err != nil {
|
||||
return err
|
||||
}
|
||||
// خرید = مالکیت + انتخابِ خودکار.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE users SET selected_carpet = ? WHERE id = ?`, carpetID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
|
||||
+117
-28
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -36,6 +37,8 @@ type Service struct {
|
||||
ad AdVerifier
|
||||
iap IAPVerifier
|
||||
|
||||
publicURL string // مبدأِ عمومیِ سرور برای ساختِ URL کاملِ تصاویر
|
||||
|
||||
mu sync.RWMutex // محافظ کشِ کاتالوگ
|
||||
cat Catalog // کشِ کاتالوگ خواندهشده از DB
|
||||
}
|
||||
@@ -44,6 +47,18 @@ func New(db *sql.DB, ad AdVerifier, iap IAPVerifier) *Service {
|
||||
return &Service{db: db, ad: ad, iap: iap}
|
||||
}
|
||||
|
||||
// SetPublicURL مبدأِ عمومیِ سرور را تنظیم میکند (برای URLهای کاملِ تصاویر).
|
||||
func (s *Service) SetPublicURL(u string) { s.publicURL = strings.TrimRight(u, "/") }
|
||||
|
||||
// imgURL یک URLِ کاملِ URL-safe برای فایلی در پوشهی [dir] میسازد ("" اگر نام خالی
|
||||
// باشد). نامِ فایل encode میشود تا فاصله/کاراکترِ خاص مشکلی ایجاد نکند.
|
||||
func (s *Service) imgURL(dir, name string) string {
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
return s.publicURL + "/" + dir + "/" + url.PathEscape(name)
|
||||
}
|
||||
|
||||
// Profile وضعیت اقتصادی کاربر.
|
||||
type Profile struct {
|
||||
Coins int64 `json:"coins"`
|
||||
@@ -57,19 +72,29 @@ type Profile struct {
|
||||
VIPUntil *string `json:"vip_until"`
|
||||
SelectedCard string `json:"selected_card"`
|
||||
SelectedCarpet string `json:"selected_carpet"`
|
||||
SelectedFrame string `json:"selected_frame"`
|
||||
SelectedAvatar string `json:"selected_avatar"` // شناسهی شخصیتِ انتخابی (برای وضعیتِ انتخاب در فروشگاه)
|
||||
SelectedAvatarImg string `json:"selected_avatar_img"` // URL کاملِ تصویرِ شخصیت — خالی ⇒ آواتارِ تصادفی
|
||||
SelectedCarpetImg string `json:"selected_carpet_img"` // URL کاملِ تصویرِ فرش — خالی ⇒ میزِ کلاسیک
|
||||
RankPoints int64 `json:"rank_points"`
|
||||
RankTier string `json:"rank_tier"` // bronze..king
|
||||
RankIndex int `json:"rank_index"` // ۰..۴
|
||||
Avatar string `json:"avatar"` // seed آواتار (برای نمایش سرِ میز)
|
||||
DailyStreak int `json:"daily_streak"`
|
||||
BestStreak int `json:"best_streak"`
|
||||
DailyReady bool `json:"daily_ready"` // آیا امروز سکهی روزانه قابلِ دریافت است
|
||||
NextDaily int64 `json:"next_daily"` // جایزهی روزانهی بعدی (پیشنمایش)
|
||||
}
|
||||
|
||||
// GetProfile وضعیت اقتصادی کاربر را میخواند.
|
||||
func (s *Service) GetProfile(ctx context.Context, userID int64) (*Profile, error) {
|
||||
var p Profile
|
||||
var vipUntil, avatar sql.NullString
|
||||
var vipUntil, avatar, lastDaily, avatarImg sql.NullString
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT coins, tickets, xp, trophies, vip_until, selected_card, selected_carpet, rank_points, avatar FROM users WHERE id = ?`, userID).
|
||||
Scan(&p.Coins, &p.Tickets, &p.XP, &p.Trophies, &vipUntil, &p.SelectedCard, &p.SelectedCarpet, &p.RankPoints, &avatar)
|
||||
`SELECT coins, tickets, xp, trophies, vip_until, selected_card, selected_carpet, selected_frame, selected_avatar,
|
||||
(SELECT id || '.' || img_ext FROM avatars WHERE id = users.selected_avatar AND enabled = 1),
|
||||
rank_points, avatar, daily_streak, best_streak, last_daily_at FROM users WHERE id = ?`, userID).
|
||||
Scan(&p.Coins, &p.Tickets, &p.XP, &p.Trophies, &vipUntil, &p.SelectedCard, &p.SelectedCarpet, &p.SelectedFrame, &p.SelectedAvatar, &avatarImg, &p.RankPoints, &avatar, &p.DailyStreak, &p.BestStreak, &lastDaily)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -77,12 +102,32 @@ func (s *Service) GetProfile(ctx context.Context, userID int64) (*Profile, error
|
||||
return nil, err
|
||||
}
|
||||
p.Avatar = avatar.String
|
||||
p.SelectedAvatarImg = s.imgURL("avatars", avatarImg.String)
|
||||
// فرش: 'classic' تصویری ندارد (میزِ ترسیمی)؛ بقیه از /carpets/<id>.jpg.
|
||||
if p.SelectedCarpet != "" && p.SelectedCarpet != "classic" {
|
||||
p.SelectedCarpetImg = s.imgURL("carpets", p.SelectedCarpet+".jpg")
|
||||
}
|
||||
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())
|
||||
}
|
||||
// وضعیتِ سکهی روزانه: اگر امروز (تهران) نگرفته، قابلِ دریافت است. اگر یک روز
|
||||
// جا افتاده باشد، استریکِ بعدی از ۱ شروع میشود.
|
||||
today := tehranDate(nowUTC())
|
||||
nextStreak := p.DailyStreak + 1
|
||||
if lastDaily.Valid {
|
||||
lastDate := tehranDate(parseTS(lastDaily.String))
|
||||
p.DailyReady = lastDate != today
|
||||
if lastDate != tehranDate(nowUTC().Add(-24*time.Hour)) && lastDate != today {
|
||||
nextStreak = 1 // روزی جا افتاده ⇒ ریست
|
||||
}
|
||||
} else {
|
||||
p.DailyReady = true
|
||||
nextStreak = 1
|
||||
}
|
||||
p.NextDaily = streakReward(nextStreak)
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
@@ -94,32 +139,70 @@ func (s *Service) isVIP(ctx context.Context, userID int64) bool {
|
||||
return vu.Valid && parseTS(vu.String).After(nowUTC())
|
||||
}
|
||||
|
||||
// ClaimDaily سکه روزانه را در صورت گذشت ۲۴ ساعت میدهد.
|
||||
func (s *Service) ClaimDaily(ctx context.Context, userID int64) (int64, error) {
|
||||
// tehranDate تاریخِ تقویمیِ تهران (YYYY-MM-DD) را برمیگرداند. مرزِ «روز» برای
|
||||
// استریک، نیمهشبِ تهران است تا با حسِ کاربر بخواند.
|
||||
func tehranDate(t time.Time) string { return t.In(iranZone).Format("2006-01-02") }
|
||||
|
||||
// streakReward جایزهی روزِ n اُمِ استریک: پایه + پله (سقفِ روز ۷) + پاداشِ هفتگی.
|
||||
func streakReward(streak int) int64 {
|
||||
n := streak
|
||||
if n > 7 {
|
||||
n = 7
|
||||
}
|
||||
r := int64(DailyReward) + int64(n-1)*50 // روز۱=۲۰۰ … روز۷=۵۰۰
|
||||
if streak%7 == 0 { // هر ۷ روز پیاپی، پاداشِ ویژه
|
||||
r += 500
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ClaimDaily سکهی روزانه را (یکبار در هر روزِ تقویمیِ تهران) میدهد و استریک را
|
||||
// بهروزرسانی میکند. برمیگرداند: جایزه، استریکِ جدید، بهترین استریک.
|
||||
func (s *Service) ClaimDaily(ctx context.Context, userID int64) (reward int64, streak int, best int, err error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
cutoff := nowUTC().Add(-24 * time.Hour).Format(tsLayout)
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`UPDATE users SET last_daily_at = ?
|
||||
WHERE id = ? AND (last_daily_at IS NULL OR last_daily_at <= ?)`,
|
||||
nowStr(), userID, cutoff)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
var last sql.NullString
|
||||
if err = tx.QueryRowContext(ctx,
|
||||
`SELECT last_daily_at, daily_streak, best_streak FROM users WHERE id = ?`, userID).
|
||||
Scan(&last, &streak, &best); err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return 0, ErrTooSoon
|
||||
|
||||
today := tehranDate(nowUTC())
|
||||
if last.Valid {
|
||||
lastDate := tehranDate(parseTS(last.String))
|
||||
if lastDate == today {
|
||||
return 0, 0, 0, ErrTooSoon // امروز قبلاً گرفته
|
||||
}
|
||||
if lastDate == tehranDate(nowUTC().Add(-24*time.Hour)) {
|
||||
streak++ // دیروز گرفته ⇒ ادامهی استریک
|
||||
} else {
|
||||
streak = 1 // یک روز جا افتاده ⇒ ریست
|
||||
}
|
||||
} else {
|
||||
streak = 1
|
||||
}
|
||||
if err := adjustTx(ctx, tx, userID, CurrencyCoin, DailyReward, "daily_reward", ""); err != nil {
|
||||
return 0, err
|
||||
if streak > best {
|
||||
best = streak
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
reward = streakReward(streak)
|
||||
|
||||
if _, err = tx.ExecContext(ctx,
|
||||
`UPDATE users SET last_daily_at = ?, daily_streak = ?, best_streak = ? WHERE id = ?`,
|
||||
nowStr(), streak, best, userID); err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
return DailyReward, nil
|
||||
if err = adjustTx(ctx, tx, userID, CurrencyCoin, reward, "daily_reward", ""); err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
return reward, streak, best, nil
|
||||
}
|
||||
|
||||
// ClaimAdReward پس از تأیید تبلیغ rewarded، سکه میدهد (با ضد تکرار و سقف روزانه).
|
||||
@@ -191,6 +274,11 @@ func (s *Service) BuyCard(ctx context.Context, userID int64, cardID string) erro
|
||||
`INSERT INTO user_cards (user_id, card_id) VALUES (?, ?)`, userID, cardID); err != nil {
|
||||
return err
|
||||
}
|
||||
// خرید = مالکیت + انتخابِ خودکار (بهعنوانِ پیشفرض فعال شود).
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE users SET selected_card = ? WHERE id = ?`, cardID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@@ -263,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 {
|
||||
@@ -271,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
|
||||
}
|
||||
@@ -319,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 // قبلاً پردازش شده
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hakemsho/internal/store"
|
||||
)
|
||||
@@ -87,10 +88,11 @@ func TestWalletInsufficient(t *testing.T) {
|
||||
func TestDailyCooldown(t *testing.T) {
|
||||
svc, id := setup(t)
|
||||
ctx := context.Background()
|
||||
if amt, err := svc.ClaimDaily(ctx, id); err != nil || amt != DailyReward {
|
||||
t.Fatalf("first claim: amt=%d err=%v", amt, err)
|
||||
// اولین دریافت: استریک ۱، جایزه = DailyReward.
|
||||
if amt, streak, _, err := svc.ClaimDaily(ctx, id); err != nil || amt != DailyReward || streak != 1 {
|
||||
t.Fatalf("first claim: amt=%d streak=%d err=%v", amt, streak, err)
|
||||
}
|
||||
if _, err := svc.ClaimDaily(ctx, id); err != ErrTooSoon {
|
||||
if _, _, _, err := svc.ClaimDaily(ctx, id); err != ErrTooSoon {
|
||||
t.Fatalf("want ErrTooSoon, got %v", err)
|
||||
}
|
||||
if c := coinsOf(t, svc, id); c != DailyReward {
|
||||
@@ -98,6 +100,33 @@ func TestDailyCooldown(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyStreakContinuesAndResets(t *testing.T) {
|
||||
svc, id := setup(t)
|
||||
ctx := context.Background()
|
||||
// دیروز گرفته ⇒ امروز استریک به ۲ میرسد و جایزه بیشتر میشود.
|
||||
ySet := func(daysAgo int) {
|
||||
ts := nowUTC().Add(time.Duration(-daysAgo) * 24 * time.Hour).Format(tsLayout)
|
||||
if _, err := svc.db.Exec(`UPDATE users SET last_daily_at=?, daily_streak=1 WHERE id=?`, ts, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
ySet(1)
|
||||
amt, streak, _, err := svc.ClaimDaily(ctx, id)
|
||||
if err != nil || streak != 2 || amt != streakReward(2) {
|
||||
t.Fatalf("continue: amt=%d streak=%d err=%v", amt, streak, err)
|
||||
}
|
||||
// دو روز فاصله ⇒ استریک ریست به ۱.
|
||||
if _, err := svc.db.Exec(
|
||||
`UPDATE users SET last_daily_at=?, daily_streak=5 WHERE id=?`,
|
||||
nowUTC().Add(-48*time.Hour).Format(tsLayout), id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, streak, _, err = svc.ClaimDaily(ctx, id)
|
||||
if err != nil || streak != 1 {
|
||||
t.Fatalf("reset: streak=%d err=%v", streak, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdRewardDedupeAndCredit(t *testing.T) {
|
||||
svc, id := setup(t)
|
||||
ctx := context.Background()
|
||||
@@ -163,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)
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package economy
|
||||
|
||||
import "context"
|
||||
|
||||
// Frame یک قابِ آواتار (کازمتیک). Owned یعنی قابلِ انتخاب. سبکِ رنگی سمتِ کلاینت است.
|
||||
type Frame struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PriceCoins int64 `json:"price_coins"`
|
||||
VIP bool `json:"vip"`
|
||||
C1 string `json:"c1"` // رنگِ گرادیان (hex بدونِ #)؛ خالی ⇒ قابِ رتبه
|
||||
C2 string `json:"c2"`
|
||||
Owned bool `json:"owned"`
|
||||
}
|
||||
|
||||
// RankTierStyle رنگ و برچسبِ یک رتبه (برنز..پادشاه). سمتِ کلاینت hardcode نیست.
|
||||
type RankTierStyle struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
C1 string `json:"c1"`
|
||||
C2 string `json:"c2"`
|
||||
}
|
||||
|
||||
// RankTiers رنگ/برچسبِ همهی رتبهها را برمیگرداند (برای کشِ کلاینت).
|
||||
func (s *Service) RankTiers(ctx context.Context) ([]RankTierStyle, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, label, c1, c2 FROM rank_tiers ORDER BY sort`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var tiers []RankTierStyle
|
||||
for rows.Next() {
|
||||
var t RankTierStyle
|
||||
if err := rows.Scan(&t.ID, &t.Label, &t.C1, &t.C2); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tiers = append(tiers, t)
|
||||
}
|
||||
return tiers, rows.Err()
|
||||
}
|
||||
|
||||
// Frames قابهای فعال را با وضعیتِ مالکیتِ کاربر برمیگرداند.
|
||||
func (s *Service) Frames(ctx context.Context, userID int64) ([]Frame, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, title, price_coins, vip, c1, c2 FROM avatar_frames WHERE enabled = 1 ORDER BY sort`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var frames []Frame
|
||||
for rows.Next() {
|
||||
var f Frame
|
||||
var vip int
|
||||
if err := rows.Scan(&f.ID, &f.Title, &f.PriceCoins, &vip, &f.C1, &f.C2); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.VIP = vip == 1
|
||||
frames = append(frames, f)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
owned, err := s.ownedFrames(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
isVIP := s.isVIP(ctx, userID)
|
||||
for i := range frames {
|
||||
f := &frames[i]
|
||||
f.Owned = (f.PriceCoins == 0 && !f.VIP) || (f.VIP && isVIP) || owned[f.ID]
|
||||
}
|
||||
return frames, nil
|
||||
}
|
||||
|
||||
func (s *Service) ownedFrames(ctx context.Context, userID int64) (map[string]bool, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT frame_id FROM user_frames WHERE user_id = ?`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
owned := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owned[id] = true
|
||||
}
|
||||
return owned, rows.Err()
|
||||
}
|
||||
|
||||
// BuyFrame یک قاب را با سکه باز میکند.
|
||||
func (s *Service) BuyFrame(ctx context.Context, userID int64, frameID string) error {
|
||||
var price int64
|
||||
var vip, enabled int
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT price_coins, vip, enabled FROM avatar_frames WHERE id = ?`, frameID).
|
||||
Scan(&price, &vip, &enabled)
|
||||
if err != nil || enabled != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var exists int
|
||||
_ = tx.QueryRowContext(ctx,
|
||||
`SELECT 1 FROM user_frames WHERE user_id = ? AND frame_id = ?`, userID, frameID).Scan(&exists)
|
||||
if exists == 1 {
|
||||
return ErrAlreadyOwned
|
||||
}
|
||||
if price > 0 {
|
||||
if err := adjustTx(ctx, tx, userID, CurrencyCoin, -price, "buy_frame", frameID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO user_frames (user_id, frame_id) VALUES (?, ?)`, userID, frameID); err != nil {
|
||||
return err
|
||||
}
|
||||
// خرید = مالکیت + انتخابِ خودکار.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE users SET selected_frame = ? WHERE id = ?`, frameID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// SelectFrame قابِ انتخابیِ کاربر را تنظیم میکند (باید مالکش باشد یا رایگان/VIP).
|
||||
func (s *Service) SelectFrame(ctx context.Context, userID int64, frameID string) error {
|
||||
frames, err := s.Frames(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, f := range frames {
|
||||
if f.ID == frameID {
|
||||
if !f.Owned {
|
||||
return ErrNotOwned
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE users SET selected_frame = ? WHERE id = ?`, frameID, userID)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return ErrNotFound
|
||||
}
|
||||
+205
-2
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"hakemsho/internal/auth"
|
||||
"hakemsho/internal/httpx"
|
||||
@@ -154,6 +155,142 @@ func (h *Handler) carpetAction(w http.ResponseWriter, r *http.Request, sel bool)
|
||||
}
|
||||
}
|
||||
|
||||
// Frames — GET /api/frames (قابهای آواتار + مالکیت + انتخابِ کاربر)
|
||||
func (h *Handler) Frames(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := uid(r)
|
||||
if !ok {
|
||||
httpx.Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
frames, err := h.svc.Frames(r.Context(), id)
|
||||
if err != nil {
|
||||
httpx.Error(w, http.StatusInternalServerError, "server error")
|
||||
return
|
||||
}
|
||||
selected := ""
|
||||
if p, _ := h.svc.GetProfile(r.Context(), id); p != nil {
|
||||
selected = p.SelectedFrame
|
||||
}
|
||||
tiers, _ := h.svc.RankTiers(r.Context())
|
||||
httpx.JSON(w, http.StatusOK, map[string]any{
|
||||
"frames": frames,
|
||||
"selected": selected,
|
||||
"rank_tiers": tiers,
|
||||
})
|
||||
}
|
||||
|
||||
// BuyFrame — POST /api/shop/buy-frame
|
||||
// Avatars — GET /api/avatars: کاتالوگِ شخصیتها + انتخابیِ کاربر.
|
||||
func (h *Handler) Avatars(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := uid(r)
|
||||
if !ok {
|
||||
httpx.Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
avatars, err := h.svc.Avatars(r.Context(), id)
|
||||
if err != nil {
|
||||
httpx.Error(w, http.StatusInternalServerError, "server error")
|
||||
return
|
||||
}
|
||||
selected := ""
|
||||
if p, _ := h.svc.GetProfile(r.Context(), id); p != nil {
|
||||
selected = p.SelectedAvatar
|
||||
}
|
||||
httpx.JSON(w, http.StatusOK, map[string]any{
|
||||
"avatars": avatars,
|
||||
"selected": selected,
|
||||
})
|
||||
}
|
||||
|
||||
// BuyAvatar — POST /api/shop/buy-avatar
|
||||
func (h *Handler) BuyAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
h.avatarAction(w, r, false)
|
||||
}
|
||||
|
||||
// SelectAvatar — POST /api/shop/select-avatar (avatar_id خالی ⇒ آواتارِ پیشفرض)
|
||||
func (h *Handler) SelectAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
h.avatarAction(w, r, true)
|
||||
}
|
||||
|
||||
func (h *Handler) avatarAction(w http.ResponseWriter, r *http.Request, sel bool) {
|
||||
id, ok := uid(r)
|
||||
if !ok {
|
||||
httpx.Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
AvatarID string `json:"avatar_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httpx.Error(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
var err error
|
||||
if sel {
|
||||
err = h.svc.SelectAvatar(r.Context(), id, req.AvatarID)
|
||||
} else {
|
||||
err = h.svc.BuyAvatar(r.Context(), id, req.AvatarID)
|
||||
}
|
||||
switch {
|
||||
case err == nil:
|
||||
httpx.JSON(w, http.StatusOK, map[string]string{"message": "ok"})
|
||||
case errors.Is(err, ErrNotFound):
|
||||
httpx.Error(w, http.StatusNotFound, "avatar not found")
|
||||
case errors.Is(err, ErrAlreadyOwned):
|
||||
httpx.Error(w, http.StatusConflict, "already owned")
|
||||
case errors.Is(err, ErrNotOwned):
|
||||
httpx.Error(w, http.StatusForbidden, "avatar not owned")
|
||||
case errors.Is(err, ErrInsufficient):
|
||||
httpx.Error(w, http.StatusPaymentRequired, "insufficient coins")
|
||||
default:
|
||||
httpx.Error(w, http.StatusInternalServerError, "server error")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) BuyFrame(w http.ResponseWriter, r *http.Request) {
|
||||
h.frameAction(w, r, false)
|
||||
}
|
||||
|
||||
// SelectFrame — POST /api/shop/select-frame
|
||||
func (h *Handler) SelectFrame(w http.ResponseWriter, r *http.Request) {
|
||||
h.frameAction(w, r, true)
|
||||
}
|
||||
|
||||
func (h *Handler) frameAction(w http.ResponseWriter, r *http.Request, sel bool) {
|
||||
id, ok := uid(r)
|
||||
if !ok {
|
||||
httpx.Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
FrameID string `json:"frame_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httpx.Error(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
var err error
|
||||
if sel {
|
||||
err = h.svc.SelectFrame(r.Context(), id, req.FrameID)
|
||||
} else {
|
||||
err = h.svc.BuyFrame(r.Context(), id, req.FrameID)
|
||||
}
|
||||
switch {
|
||||
case err == nil:
|
||||
httpx.JSON(w, http.StatusOK, map[string]string{"message": "ok"})
|
||||
case errors.Is(err, ErrNotFound):
|
||||
httpx.Error(w, http.StatusNotFound, "frame not found")
|
||||
case errors.Is(err, ErrAlreadyOwned):
|
||||
httpx.Error(w, http.StatusConflict, "already owned")
|
||||
case errors.Is(err, ErrNotOwned):
|
||||
httpx.Error(w, http.StatusForbidden, "frame not owned")
|
||||
case errors.Is(err, ErrInsufficient):
|
||||
httpx.Error(w, http.StatusPaymentRequired, "insufficient coins")
|
||||
default:
|
||||
httpx.Error(w, http.StatusInternalServerError, "server error")
|
||||
}
|
||||
}
|
||||
|
||||
// ChatPacks — GET /api/chat-packs (بستههای پیام/شکلک + مالکیتِ کاربر)
|
||||
func (h *Handler) ChatPacks(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := uid(r)
|
||||
@@ -197,6 +334,70 @@ func (h *Handler) BuyChatPack(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Tournaments — GET /api/tournaments (فهرست با وضعیتِ عضویتِ کاربر)
|
||||
func (h *Handler) Tournaments(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := uid(r)
|
||||
if !ok {
|
||||
httpx.Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
list, err := h.svc.ListTournaments(r.Context(), id)
|
||||
if err != nil {
|
||||
httpx.Error(w, http.StatusInternalServerError, "server error")
|
||||
return
|
||||
}
|
||||
httpx.JSON(w, http.StatusOK, map[string]any{"tournaments": list})
|
||||
}
|
||||
|
||||
// TournamentStandings — GET /api/tournaments/standings?id=X (ردهبندی)
|
||||
func (h *Handler) TournamentStandings(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := uid(r); !ok {
|
||||
httpx.Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
tid, _ := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64)
|
||||
if tid == 0 {
|
||||
httpx.Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
rows, err := h.svc.TournamentStandings(r.Context(), tid, 50)
|
||||
if err != nil {
|
||||
httpx.Error(w, http.StatusInternalServerError, "server error")
|
||||
return
|
||||
}
|
||||
httpx.JSON(w, http.StatusOK, map[string]any{"standings": rows})
|
||||
}
|
||||
|
||||
// JoinTournament — POST /api/tournaments/join {id}
|
||||
func (h *Handler) JoinTournament(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := uid(r)
|
||||
if !ok {
|
||||
httpx.Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httpx.Error(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
switch err := h.svc.JoinTournament(r.Context(), id, req.ID); {
|
||||
case err == nil:
|
||||
httpx.JSON(w, http.StatusOK, map[string]string{"message": "ثبتنام انجام شد"})
|
||||
case errors.Is(err, ErrTournamentNotFound):
|
||||
httpx.Error(w, http.StatusNotFound, "تورنومنت یافت نشد")
|
||||
case errors.Is(err, ErrTournamentClosed):
|
||||
httpx.Error(w, http.StatusConflict, "مهلتِ ثبتنام تمام شده")
|
||||
case errors.Is(err, ErrAlreadyJoined):
|
||||
httpx.Error(w, http.StatusConflict, "قبلاً ثبتنام کردهاید")
|
||||
case errors.Is(err, ErrInsufficient):
|
||||
httpx.Error(w, http.StatusPaymentRequired, "insufficient coins")
|
||||
default:
|
||||
httpx.Error(w, http.StatusInternalServerError, "server error")
|
||||
}
|
||||
}
|
||||
|
||||
// SelectCard — POST /api/shop/select-card
|
||||
func (h *Handler) SelectCard(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := uid(r)
|
||||
@@ -310,9 +511,11 @@ func (h *Handler) Daily(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
switch amount, err := h.svc.ClaimDaily(r.Context(), id); {
|
||||
switch amount, streak, best, err := h.svc.ClaimDaily(r.Context(), id); {
|
||||
case err == nil:
|
||||
httpx.JSON(w, http.StatusOK, map[string]any{"message": "ok", "amount": amount})
|
||||
httpx.JSON(w, http.StatusOK, map[string]any{
|
||||
"message": "ok", "amount": amount, "streak": streak, "best": best,
|
||||
})
|
||||
case errors.Is(err, ErrTooSoon):
|
||||
httpx.Error(w, http.StatusTooManyRequests, "already claimed today")
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
package economy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// امتیازِ هر رویداد در تورنومنت.
|
||||
const (
|
||||
tourPointsWin = 100 // بردِ یک بازی
|
||||
tourPointsLoss = 25 // شرکت در یک بازی (باخت)
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTournamentNotFound = errors.New("tournament not found")
|
||||
ErrTournamentClosed = errors.New("tournament is closed")
|
||||
ErrAlreadyJoined = errors.New("already joined")
|
||||
)
|
||||
|
||||
// Tournament یک تورنومنتِ امتیازی.
|
||||
type Tournament struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
EntryFee int64 `json:"entry_fee"`
|
||||
Prizes []int64 `json:"prizes"`
|
||||
StartsAt time.Time `json:"-"`
|
||||
EndsAt time.Time `json:"-"`
|
||||
Settled bool `json:"-"`
|
||||
}
|
||||
|
||||
// TournamentView نمای عمومیِ تورنومنت برای کلاینت (وضعیت، جایزهی کل، عضویتِ کاربر).
|
||||
type TournamentView struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
EntryFee int64 `json:"entry_fee"`
|
||||
Prizes []int64 `json:"prizes"`
|
||||
PrizePool int64 `json:"prize_pool"` // مجموعِ جوایز
|
||||
Status string `json:"status"` // upcoming | active | ended
|
||||
StartsAt string `json:"starts_at"` // RFC3339 UTC (ماشینی)
|
||||
EndsAt string `json:"ends_at"`
|
||||
StartsLocal string `json:"starts_local"` // به وقتِ تهران (نمایشی)
|
||||
EndsLocal string `json:"ends_local"`
|
||||
Players int `json:"players"`
|
||||
Joined bool `json:"joined"`
|
||||
MyPoints int `json:"my_points"`
|
||||
MyRank int `json:"my_rank"` // ۰ یعنی رتبهای ندارد
|
||||
}
|
||||
|
||||
// TournamentStanding یک ردیفِ جدولِ ردهبندی.
|
||||
type TournamentStanding struct {
|
||||
Rank int `json:"rank"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Avatar string `json:"avatar"`
|
||||
Points int `json:"points"`
|
||||
Wins int `json:"wins"`
|
||||
Games int `json:"games"`
|
||||
Prize int64 `json:"prize"`
|
||||
}
|
||||
|
||||
// iranZone وقتِ ایران (UTC+3:30، بدونِ DST از ۲۰۲۲). ثابت است تا به tzdata
|
||||
// روی سرور وابسته نباشیم (باینریِ CGO-off).
|
||||
var iranZone = time.FixedZone("IRST", 12600)
|
||||
|
||||
func tournamentStatus(now, starts, ends time.Time) string {
|
||||
switch {
|
||||
case now.Before(starts):
|
||||
return "upcoming"
|
||||
case now.After(ends):
|
||||
return "ended"
|
||||
default:
|
||||
return "active"
|
||||
}
|
||||
}
|
||||
|
||||
func sumPrizes(p []int64) int64 {
|
||||
var s int64
|
||||
for _, x := range p {
|
||||
s += x
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// scanTournament یک ردیفِ tournaments را میخواند.
|
||||
func scanTournament(sc interface{ Scan(...any) error }) (*Tournament, error) {
|
||||
var t Tournament
|
||||
var prizes, starts, ends string
|
||||
var settled int
|
||||
if err := sc.Scan(&t.ID, &t.Title, &t.Description, &t.EntryFee, &prizes, &starts, &ends, &settled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal([]byte(prizes), &t.Prizes)
|
||||
t.StartsAt = parseTS(starts)
|
||||
t.EndsAt = parseTS(ends)
|
||||
t.Settled = settled != 0
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
const tournamentCols = `id, title, description, entry_fee, prizes, starts_at, ends_at, settled`
|
||||
|
||||
// ListTournaments تورنومنتها را (بههمراه وضعیتِ عضویتِ کاربر) برمیگرداند.
|
||||
// پیش از خواندن، تورنومنتهای پایانیافتهی تسویهنشده را تسویه میکند.
|
||||
//
|
||||
// نکتهی مهم: چون pool فقط یک اتصال دارد (SetMaxOpenConns(1))، نباید حین باز بودنِ
|
||||
// یک rows کوئریِ دیگری زد (self-deadlock). پس ابتدا همهی ردیفها را میخوانیم و
|
||||
// rows را میبندیم، سپس زیرکوئریهای هر تورنومنت را اجرا میکنیم.
|
||||
func (s *Service) ListTournaments(ctx context.Context, userID int64) ([]TournamentView, error) {
|
||||
s.SettleTournaments(ctx)
|
||||
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT `+tournamentCols+` FROM tournaments ORDER BY (ends_at < strftime('%Y-%m-%d %H:%M:%S','now')), starts_at`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ts []*Tournament
|
||||
for rows.Next() {
|
||||
t, err := scanTournament(rows)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
ts = append(ts, t)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := nowUTC()
|
||||
out := make([]TournamentView, 0, len(ts))
|
||||
for _, t := range ts {
|
||||
v := TournamentView{
|
||||
ID: t.ID, Title: t.Title, Description: t.Description,
|
||||
EntryFee: t.EntryFee, Prizes: t.Prizes, PrizePool: sumPrizes(t.Prizes),
|
||||
Status: tournamentStatus(now, t.StartsAt, t.EndsAt),
|
||||
StartsAt: t.StartsAt.Format(time.RFC3339),
|
||||
EndsAt: t.EndsAt.Format(time.RFC3339),
|
||||
StartsLocal: t.StartsAt.In(iranZone).Format("2006-01-02 15:04"),
|
||||
EndsLocal: t.EndsAt.In(iranZone).Format("2006-01-02 15:04"),
|
||||
}
|
||||
_ = s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM tournament_entries WHERE tournament_id = ?`, t.ID).Scan(&v.Players)
|
||||
if userID != 0 {
|
||||
var pts sql.NullInt64
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT points FROM tournament_entries WHERE tournament_id = ? AND user_id = ?`,
|
||||
t.ID, userID).Scan(&pts); err == nil {
|
||||
v.Joined = true
|
||||
v.MyPoints = int(pts.Int64)
|
||||
_ = s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*)+1 FROM tournament_entries WHERE tournament_id = ? AND points > ?`,
|
||||
t.ID, pts.Int64).Scan(&v.MyRank)
|
||||
}
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// TournamentStandings ردهبندیِ یک تورنومنت (تا limit نفرِ برتر).
|
||||
func (s *Service) TournamentStandings(ctx context.Context, id int64, limit int) ([]TournamentStanding, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT e.user_id, COALESCE(u.first_name, u.mobile), COALESCE(u.avatar,''),
|
||||
e.points, e.wins, e.games, e.prize
|
||||
FROM tournament_entries e JOIN users u ON u.id = e.user_id
|
||||
WHERE e.tournament_id = ?
|
||||
ORDER BY e.points DESC, e.wins DESC, e.joined_at ASC
|
||||
LIMIT ?`, id, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []TournamentStanding
|
||||
rank := 0
|
||||
for rows.Next() {
|
||||
rank++
|
||||
var st TournamentStanding
|
||||
var name sql.NullString
|
||||
if err := rows.Scan(&st.UserID, &name, &st.Avatar, &st.Points, &st.Wins, &st.Games, &st.Prize); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st.Rank = rank
|
||||
st.Name = name.String
|
||||
out = append(out, st)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// JoinTournament کاربر را ثبتنام کرده و ورودی را از سکه کسر میکند.
|
||||
func (s *Service) JoinTournament(ctx context.Context, userID, id int64) error {
|
||||
row := s.db.QueryRowContext(ctx, `SELECT `+tournamentCols+` FROM tournaments WHERE id = ?`, id)
|
||||
t, err := scanTournament(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrTournamentNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// ثبتنام تا پیش از پایانِ تورنومنت مجاز است.
|
||||
if nowUTC().After(t.EndsAt) || t.Settled {
|
||||
return ErrTournamentClosed
|
||||
}
|
||||
// آیا قبلاً عضو شده؟
|
||||
var exists int
|
||||
_ = s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM tournament_entries WHERE tournament_id = ? AND user_id = ?`,
|
||||
id, userID).Scan(&exists)
|
||||
if exists > 0 {
|
||||
return ErrAlreadyJoined
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if t.EntryFee > 0 {
|
||||
if err := adjustTx(ctx, tx, userID, CurrencyCoin, -t.EntryFee, "tournament_join", tournamentRef(id)); err != nil {
|
||||
return err // ErrInsufficient در صورتِ کمبود
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO tournament_entries (tournament_id, user_id) VALUES (?, ?)`, id, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// AddTournamentPoints امتیازِ کاربر را در همهی تورنومنتهای فعالی که در آنها عضو
|
||||
// است افزایش میدهد (هنگامِ پایانِ هر بازی). fire-and-forget؛ خطاها لاگ نمیشوند.
|
||||
func (s *Service) AddTournamentPoints(ctx context.Context, userID int64, won bool) {
|
||||
pts := tourPointsLoss
|
||||
winInc := 0
|
||||
if won {
|
||||
pts = tourPointsWin
|
||||
winInc = 1
|
||||
}
|
||||
now := nowUTC().Format(tsLayout)
|
||||
_, _ = s.db.ExecContext(ctx,
|
||||
`UPDATE tournament_entries
|
||||
SET points = points + ?, games = games + 1, wins = wins + ?
|
||||
WHERE user_id = ?
|
||||
AND tournament_id IN (
|
||||
SELECT id FROM tournaments
|
||||
WHERE settled = 0 AND starts_at <= ? AND ends_at >= ?
|
||||
)`,
|
||||
pts, winInc, userID, now, now)
|
||||
}
|
||||
|
||||
// SettleTournaments تورنومنتهای پایانیافتهی تسویهنشده را تسویه و جوایز را پرداخت میکند.
|
||||
func (s *Service) SettleTournaments(ctx context.Context) {
|
||||
now := nowUTC().Format(tsLayout)
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, prizes FROM tournaments WHERE settled = 0 AND ends_at < ?`, now)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var ids []int64
|
||||
prizeMap := map[int64][]int64{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var prizes string
|
||||
if err := rows.Scan(&id, &prizes); err != nil {
|
||||
continue
|
||||
}
|
||||
var p []int64
|
||||
_ = json.Unmarshal([]byte(prizes), &p)
|
||||
ids = append(ids, id)
|
||||
prizeMap[id] = p
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, id := range ids {
|
||||
s.settleOne(ctx, id, prizeMap[id])
|
||||
}
|
||||
}
|
||||
|
||||
// settleOne یک تورنومنت را در یک تراکنش تسویه میکند.
|
||||
func (s *Service) settleOne(ctx context.Context, id int64, prizes []int64) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if len(prizes) > 0 {
|
||||
rows, err := tx.QueryContext(ctx,
|
||||
`SELECT user_id FROM tournament_entries WHERE tournament_id = ?
|
||||
ORDER BY points DESC, wins DESC, joined_at ASC LIMIT ?`, id, len(prizes))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var winners []int64
|
||||
for rows.Next() {
|
||||
var uid int64
|
||||
if err := rows.Scan(&uid); err != nil {
|
||||
rows.Close()
|
||||
return
|
||||
}
|
||||
winners = append(winners, uid)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for i, uid := range winners {
|
||||
amt := prizes[i]
|
||||
if amt <= 0 {
|
||||
continue
|
||||
}
|
||||
if err := adjustTx(ctx, tx, uid, CurrencyCoin, amt, "tournament_prize", tournamentRef(id)); err != nil {
|
||||
return
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE tournament_entries SET prize = ? WHERE tournament_id = ? AND user_id = ?`,
|
||||
amt, id, uid); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE tournaments SET settled = 1 WHERE id = ?`, id); err != nil {
|
||||
return
|
||||
}
|
||||
_ = tx.Commit()
|
||||
}
|
||||
|
||||
func tournamentRef(id int64) string {
|
||||
return "tournament:" + strconv.FormatInt(id, 10)
|
||||
}
|
||||
|
||||
// --- ادمین: CRUD تورنومنت ---
|
||||
|
||||
// CreateTournament یک تورنومنت جدید میسازد و شناسهاش را برمیگرداند.
|
||||
func (s *Service) CreateTournament(ctx context.Context, t Tournament) (int64, error) {
|
||||
prizes, _ := json.Marshal(t.Prizes)
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO tournaments (title, description, entry_fee, prizes, starts_at, ends_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
t.Title, t.Description, t.EntryFee, string(prizes),
|
||||
t.StartsAt.UTC().Format(tsLayout), t.EndsAt.UTC().Format(tsLayout))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM tournaments WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// AdminListTournaments همهی تورنومنتها را برای پنلِ ادمین برمیگرداند.
|
||||
func (s *Service) AdminListTournaments(ctx context.Context) ([]TournamentView, error) {
|
||||
return s.ListTournaments(ctx, 0)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package economy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// makeUser یک کاربرِ آزمایشی با شمارهی دلخواه میسازد.
|
||||
func makeUser(t *testing.T, s *Service, mobile string) int64 {
|
||||
t.Helper()
|
||||
res, err := s.db.Exec(`INSERT INTO users (mobile) VALUES (?)`, mobile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return id
|
||||
}
|
||||
|
||||
func TestTournamentJoinPointsSettle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, u1 := setup(t)
|
||||
u2 := makeUser(t, svc, "09120000002")
|
||||
setCoins(t, svc, u1, 1000)
|
||||
setCoins(t, svc, u2, 1000)
|
||||
|
||||
// تورنومنتی که همین حالا فعال است.
|
||||
id, err := svc.CreateTournament(ctx, Tournament{
|
||||
Title: "شبانه",
|
||||
EntryFee: 200,
|
||||
Prizes: []int64{500, 200}, // رتبه۱=۵۰۰، رتبه۲=۲۰۰
|
||||
StartsAt: nowUTC().Add(-time.Minute),
|
||||
EndsAt: nowUTC().Add(time.Hour),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
// هر دو ثبتنام میکنند ⇒ ۲۰۰ سکه کسر میشود.
|
||||
if err := svc.JoinTournament(ctx, u1, id); err != nil {
|
||||
t.Fatalf("join u1: %v", err)
|
||||
}
|
||||
if err := svc.JoinTournament(ctx, u2, id); err != nil {
|
||||
t.Fatalf("join u2: %v", err)
|
||||
}
|
||||
if c := coinsOf(t, svc, u1); c != 800 {
|
||||
t.Errorf("entry fee not charged: coins=%d", c)
|
||||
}
|
||||
// ثبتنامِ دوباره باید خطا دهد.
|
||||
if err := svc.JoinTournament(ctx, u1, id); err != ErrAlreadyJoined {
|
||||
t.Errorf("expected ErrAlreadyJoined, got %v", err)
|
||||
}
|
||||
|
||||
// u1 دو برد، u2 یک برد ⇒ u1 رتبهی اول.
|
||||
svc.AddTournamentPoints(ctx, u1, true)
|
||||
svc.AddTournamentPoints(ctx, u1, true)
|
||||
svc.AddTournamentPoints(ctx, u2, true)
|
||||
svc.AddTournamentPoints(ctx, u2, false)
|
||||
|
||||
st, err := svc.TournamentStandings(ctx, id, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("standings: %v", err)
|
||||
}
|
||||
if len(st) != 2 || st[0].UserID != u1 || st[0].Points != 200 {
|
||||
t.Fatalf("bad standings: %+v", st)
|
||||
}
|
||||
if st[1].UserID != u2 || st[1].Points != 125 {
|
||||
t.Errorf("u2 points wrong: %+v", st[1])
|
||||
}
|
||||
|
||||
// پایانِ تورنومنت را به گذشته میبریم؛ تسویه جوایز را پرداخت میکند.
|
||||
if _, err := svc.db.Exec(`UPDATE tournaments SET ends_at = ? WHERE id = ?`,
|
||||
nowUTC().Add(-time.Minute).Format(tsLayout), id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc.SettleTournaments(ctx)
|
||||
|
||||
if c := coinsOf(t, svc, u1); c != 800+500 {
|
||||
t.Errorf("u1 prize not paid: coins=%d (want %d)", c, 800+500)
|
||||
}
|
||||
if c := coinsOf(t, svc, u2); c != 800+200 {
|
||||
t.Errorf("u2 prize not paid: coins=%d (want %d)", c, 800+200)
|
||||
}
|
||||
|
||||
// تسویهی دوباره نباید دوباره پرداخت کند.
|
||||
svc.SettleTournaments(ctx)
|
||||
if c := coinsOf(t, svc, u1); c != 1300 {
|
||||
t.Errorf("double settle paid again: coins=%d", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTournamentsWithRows(t *testing.T) {
|
||||
// رگرسیون: با MaxOpenConns(1)، ListTournaments نباید حین باز بودنِ rows
|
||||
// زیرکوئری بزند (self-deadlock). این تست با تورنومنتِ واقعی آن مسیر را میپیماید.
|
||||
ctx := context.Background()
|
||||
svc, u1 := setup(t)
|
||||
setCoins(t, svc, u1, 1000)
|
||||
id, err := svc.CreateTournament(ctx, Tournament{
|
||||
Title: "لیست", EntryFee: 100, Prizes: []int64{500},
|
||||
StartsAt: nowUTC().Add(-time.Minute), EndsAt: nowUTC().Add(time.Hour),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.JoinTournament(ctx, u1, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc.AddTournamentPoints(ctx, u1, true)
|
||||
|
||||
done := make(chan []TournamentView, 1)
|
||||
go func() {
|
||||
v, e := svc.ListTournaments(ctx, u1)
|
||||
if e != nil {
|
||||
t.Errorf("list: %v", e)
|
||||
}
|
||||
done <- v
|
||||
}()
|
||||
select {
|
||||
case v := <-done:
|
||||
if len(v) != 1 || !v[0].Joined || v[0].MyPoints != 100 ||
|
||||
v[0].Players != 1 || v[0].MyRank != 1 {
|
||||
t.Errorf("bad view: %+v", v)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("ListTournaments deadlocked (nested query on single conn)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTournamentJoinInsufficient(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, u1 := setup(t)
|
||||
setCoins(t, svc, u1, 50)
|
||||
id, _ := svc.CreateTournament(ctx, Tournament{
|
||||
Title: "گران", EntryFee: 500, Prizes: []int64{100},
|
||||
StartsAt: nowUTC().Add(-time.Minute), EndsAt: nowUTC().Add(time.Hour),
|
||||
})
|
||||
if err := svc.JoinTournament(ctx, u1, id); err != ErrInsufficient {
|
||||
t.Errorf("expected ErrInsufficient, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -243,6 +243,9 @@ func (g *Game) trickWinner() int {
|
||||
return best.Seat
|
||||
}
|
||||
|
||||
// Beats نسخهی عمومیِ beats برای استفادهی هوشِ باتِ لایهی ws.
|
||||
func Beats(a, b Card, trump, lead Suit) bool { return beats(a, b, trump, lead) }
|
||||
|
||||
// beats مشخص میکند آیا a کارت b را میبرد (با توجه به حکم و خال زمینه).
|
||||
func beats(a, b Card, trump, lead Suit) bool {
|
||||
aTrump := a.Suit == trump
|
||||
|
||||
@@ -49,6 +49,29 @@ func CardsHandler(diskDir string) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// AvatarsHandler تصاویرِ شخصیتِ آواتار را سرو میکند: GET /avatars/<id>.png
|
||||
// فقط از پوشهی دیسکیِ آپلودِ ادمین (بدونِ embed)؛ اگر نبود ۴۰۴ (اپ به آواتارِ
|
||||
// تصادفیِ پیشفرض برمیگردد).
|
||||
func AvatarsHandler(diskDir string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Cache-Control", "public, max-age=604800")
|
||||
if diskDir != "" {
|
||||
rel := filepath.Clean("/" + strings.TrimPrefix(r.URL.Path, "/avatars/"))
|
||||
full := filepath.Join(diskDir, rel)
|
||||
if st, err := os.Stat(full); err == nil && !st.IsDir() {
|
||||
// SVG را با content-type درست بده تا مرورگر/پنل هم آن را نمایش دهد.
|
||||
if strings.HasSuffix(strings.ToLower(full), ".svg") {
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
}
|
||||
http.ServeFile(w, r, full)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// CarpetsHandler تصاویرِ فرش را سرو میکند: GET /carpets/<id>.jpg
|
||||
// ابتدا از پوشهی دیسکی (آپلودِ ادمین)، سپس از فرشهای embedشدهی پیشفرض.
|
||||
// اگر هیچکدام نبود ۴۰۴ (اپ به میزِ ترسیمیِ classic برمیگردد).
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestConcurrentReadWrite تأیید میکند که با pool چند-اتصالی + WAL + busy_timeout،
|
||||
// خواندن و نوشتنِ همزمان بدونِ خطای «database is locked» انجام میشود.
|
||||
func TestConcurrentReadWrite(t *testing.T) {
|
||||
s, err := Open(t.TempDir() + "/c.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
if _, err := s.DB.Exec(`CREATE TABLE t(id INTEGER PRIMARY KEY, v INTEGER)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const workers = 40 // ۲۰ نویسنده + ۲۰ خواننده همزمان
|
||||
const iters = 50
|
||||
var wg sync.WaitGroup
|
||||
var writeErr, readErr int64
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iters; i++ {
|
||||
if w%2 == 0 {
|
||||
if _, err := s.DB.Exec(`INSERT INTO t(v) VALUES(?)`, i); err != nil {
|
||||
atomic.AddInt64(&writeErr, 1)
|
||||
t.Logf("write err: %v", err)
|
||||
}
|
||||
} else {
|
||||
var n int
|
||||
if err := s.DB.QueryRow(`SELECT COUNT(*) FROM t`).Scan(&n); err != nil {
|
||||
atomic.AddInt64(&readErr, 1)
|
||||
t.Logf("read err: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if writeErr != 0 || readErr != 0 {
|
||||
t.Fatalf("concurrency errors: writes=%d reads=%d (expected 0)", writeErr, readErr)
|
||||
}
|
||||
var total int
|
||||
_ = s.DB.QueryRow(`SELECT COUNT(*) FROM t`).Scan(&total)
|
||||
if want := (workers / 2) * iters; total != want {
|
||||
t.Fatalf("expected %d rows written, got %d", want, total)
|
||||
}
|
||||
}
|
||||
+10
-4
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite" // درایور SQLite خالص Go (بدون cgo)
|
||||
)
|
||||
@@ -20,14 +21,19 @@ type Store struct {
|
||||
|
||||
// Open اتصال SQLite را باز کرده و migrations را اجرا میکند.
|
||||
func Open(path string) (*Store, error) {
|
||||
// pragmaها برای کارایی و یکپارچگی روی سرور کوچک
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)", path)
|
||||
// pragmaها برای کارایی و یکپارچگی روی سرور کوچک. busy_timeout بالاتر تا نویسندهی
|
||||
// دوم بهجای خطای «database is locked» چند لحظه منتظر بماند.
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(10000)&_pragma=foreign_keys(ON)", path)
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// SQLite تکنویسنده است؛ pool کوچک نگه میداریم.
|
||||
db.SetMaxOpenConns(1)
|
||||
// WAL اجازه میدهد چند «خواننده» همزمان باشند و فقط «نویسنده» تک بماند. با
|
||||
// SetMaxOpenConns(1) این مزیت هدر میرفت (همه، حتی خواندنها، در صفِ تکاتصالی).
|
||||
// pool بزرگتر ⇒ خواندنهای همزمان بدونِ صف؛ نوشتنها با busy_timeout سریالی میمانند.
|
||||
db.SetMaxOpenConns(10)
|
||||
db.SetMaxIdleConns(10)
|
||||
db.SetConnMaxIdleTime(5 * time.Minute)
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
-- تورنومنتِ امتیازی: بازیکنان با پرداختِ ورودی ثبتنام میکنند و در بازهی
|
||||
-- تورنومنت با بردنِ بازیها امتیاز میگیرند؛ در پایان، نفراتِ برتر جایزه میگیرند.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tournaments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
entry_fee INTEGER NOT NULL DEFAULT 0, -- ورودی به سکه
|
||||
prizes TEXT NOT NULL DEFAULT '[]', -- JSON: سکهی جایزه بهترتیبِ رتبه [رتبه۱, رتبه۲, ...]
|
||||
starts_at TEXT NOT NULL, -- زمانِ شروع (UTC)
|
||||
ends_at TEXT NOT NULL, -- زمانِ پایان (UTC)
|
||||
settled INTEGER NOT NULL DEFAULT 0, -- آیا جوایز پرداخت شده
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tournament_entries (
|
||||
tournament_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
points INTEGER NOT NULL DEFAULT 0,
|
||||
games INTEGER NOT NULL DEFAULT 0,
|
||||
wins INTEGER NOT NULL DEFAULT 0,
|
||||
prize INTEGER NOT NULL DEFAULT 0, -- سکهی جایزهی دریافتی (پس از تسویه)
|
||||
joined_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now')),
|
||||
PRIMARY KEY (tournament_id, user_id),
|
||||
FOREIGN KEY (tournament_id) REFERENCES tournaments(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tournament_entries_user ON tournament_entries(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tournaments_window ON tournaments(ends_at, settled);
|
||||
@@ -0,0 +1,11 @@
|
||||
-- رتبه از بازیِ عمومی: بهجای یک میزِ «رتبهبندی»ِ جدا (که کاربر را گیج میکرد)،
|
||||
-- هر میزِ عمومی به برنده/بازنده امتیازِ رتبه میدهد (متناسب با سطحِ میز). میزِ
|
||||
-- تورنومنت هم عمومی است و همین امتیاز را میدهد. میزِ اختصاصیِ «رتبهبندی» حذف میشود.
|
||||
|
||||
UPDATE table_tiers SET rank_reward = 10 WHERE id = 'beginner';
|
||||
UPDATE table_tiers SET rank_reward = 20 WHERE id = 'amateur';
|
||||
UPDATE table_tiers SET rank_reward = 25 WHERE id = 'pro';
|
||||
UPDATE table_tiers SET rank_reward = 40 WHERE id = 'master';
|
||||
|
||||
-- حذفِ میزِ رتبهبندیِ اختصاصی از انتخابِ میز (سیستمِ رتبه و جدولِ قهرمانان میماند).
|
||||
UPDATE table_tiers SET enabled = 0 WHERE id = 'ranked';
|
||||
@@ -0,0 +1,4 @@
|
||||
-- استریکِ روزانه (مثلِ دولینگو): تعدادِ روزهای پیاپی که کاربر سکهی روزانه گرفته.
|
||||
-- با از دست دادنِ یک روز صفر میشود؛ جایزه با طولِ استریک زیاد میشود.
|
||||
ALTER TABLE users ADD COLUMN daily_streak INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE users ADD COLUMN best_streak INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- قابهای آواتار (کازمتیکِ قابلِخرید). سبکِ هر قاب (رنگها) سمتِ کلاینت است؛
|
||||
-- اینجا فقط شناسه/قیمت/فعالبودن نگهداری میشود. 'none' یعنی قابِ پیشفرضِ رتبه.
|
||||
CREATE TABLE IF NOT EXISTS avatar_frames (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
price_coins INTEGER NOT NULL DEFAULT 0, -- ۰ یعنی رایگان (مگر vip)
|
||||
vip INTEGER NOT NULL DEFAULT 0, -- ۱ یعنی برای VIP رایگان
|
||||
sort INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_frames (
|
||||
user_id INTEGER NOT NULL,
|
||||
frame_id TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, frame_id)
|
||||
);
|
||||
|
||||
-- قابِ انتخابیِ کاربر؛ خالی/'none' یعنی قابِ پیشفرضِ رتبهای.
|
||||
ALTER TABLE users ADD COLUMN selected_frame TEXT NOT NULL DEFAULT '';
|
||||
|
||||
INSERT OR IGNORE INTO avatar_frames (id, title, price_coins, vip, sort) VALUES
|
||||
('none', 'پیشفرض (رتبه)', 0, 0, 1),
|
||||
('gold', 'قابِ طلایی', 5000, 0, 2),
|
||||
('fire', 'قابِ آتش', 12000, 0, 3),
|
||||
('emerald', 'قابِ زمرد', 12000, 0, 4),
|
||||
('ocean', 'قابِ اقیانوس', 15000, 0, 5),
|
||||
('rose', 'قابِ گلسرخ', 15000, 0, 6),
|
||||
('royal', 'قابِ سلطنتی', 0, 1, 7);
|
||||
@@ -0,0 +1,11 @@
|
||||
-- رنگِ گرادیانِ هر قاب سمتِ سرور تعریف میشود (hex بدونِ #)، تا ادمین بتواند قابِ
|
||||
-- جدید با رنگِ دلخواه بسازد بدونِ بهروزرسانیِ اپ. خالی ⇒ قابِ رتبه.
|
||||
ALTER TABLE avatar_frames ADD COLUMN c1 TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE avatar_frames ADD COLUMN c2 TEXT NOT NULL DEFAULT '';
|
||||
|
||||
UPDATE avatar_frames SET c1='F3D27A', c2='B8860B' WHERE id='gold';
|
||||
UPDATE avatar_frames SET c1='FF9A3C', c2='B81D2A' WHERE id='fire';
|
||||
UPDATE avatar_frames SET c1='7BEAA5', c2='1B7A4A' WHERE id='emerald';
|
||||
UPDATE avatar_frames SET c1='8FD3F0', c2='1E5FA8' WHERE id='ocean';
|
||||
UPDATE avatar_frames SET c1='F7A8C4', c2='A83060' WHERE id='rose';
|
||||
UPDATE avatar_frames SET c1='CE9BEA', c2='6A2FA0' WHERE id='royal';
|
||||
@@ -0,0 +1,15 @@
|
||||
-- رنگ و برچسبِ رتبهها از سرور میآید (تا هیچ رنگی سمتِ کلاینت hardcode نباشد).
|
||||
CREATE TABLE IF NOT EXISTS rank_tiers (
|
||||
id TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
c1 TEXT NOT NULL,
|
||||
c2 TEXT NOT NULL,
|
||||
sort INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
INSERT INTO rank_tiers (id, label, c1, c2, sort) VALUES
|
||||
('bronze', 'برنز', 'CD7F32', '8C5A2B', 1),
|
||||
('silver', 'نقره', 'BFC6CC', '8A949B', 2),
|
||||
('gold', 'طلا', 'FFD54F', 'B8860B', 3),
|
||||
('diamond', 'الماس', '5BE0E6', '1E8A99', 4),
|
||||
('king', 'پادشاه', 'B388FF', '6A1B9A', 5);
|
||||
@@ -0,0 +1,20 @@
|
||||
-- شخصیتهای آواتار (کازمتیک تصویری): کاربر میخرد و در کلِ بازی نمایش داده میشود.
|
||||
-- تصویرِ هر آواتار از پنل ادمین آپلود و در /avatars/<id>.png سرو میشود.
|
||||
CREATE TABLE IF NOT EXISTS avatars (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
price_coins INTEGER NOT NULL DEFAULT 0,
|
||||
vip INTEGER NOT NULL DEFAULT 0,
|
||||
sort INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
img_ext TEXT NOT NULL DEFAULT 'png' -- png یا svg (بر اساسِ فایلِ آپلودشده)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_avatars (
|
||||
user_id INTEGER NOT NULL,
|
||||
avatar_id TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, avatar_id)
|
||||
);
|
||||
|
||||
-- شخصیتِ انتخابیِ کاربر؛ خالی ⇒ آواتارِ تصادفیِ پیشفرض (بر اساسِ seed).
|
||||
ALTER TABLE users ADD COLUMN selected_avatar TEXT NOT NULL DEFAULT '';
|
||||
@@ -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);
|
||||
+55
-24
@@ -13,7 +13,7 @@ const pageHTML = `<!doctype html>
|
||||
body { margin:0; font-family: -apple-system, "Segoe UI", Tahoma, sans-serif;
|
||||
background:#0E2347; color:#E8EEF7; }
|
||||
header { padding:14px 20px; background:#17345C; border-bottom:1px solid #1E5FA8;
|
||||
display:flex; align-items:center; gap:14px; position:sticky; top:0; }
|
||||
display:flex; align-items:center; gap:14px; position:sticky; top:0; z-index:2; }
|
||||
header h1 { font-size:17px; margin:0; font-weight:700; }
|
||||
header .dot { width:9px; height:9px; border-radius:50%; background:#4ade80; }
|
||||
header .meta { margin-inline-start:auto; font-size:12px; opacity:.7; }
|
||||
@@ -22,24 +22,34 @@ const pageHTML = `<!doctype html>
|
||||
.filters input, .filters select {
|
||||
background:#0b1c39; color:#E8EEF7; border:1px solid #1E5FA8;
|
||||
border-radius:8px; padding:7px 10px; font-size:13px; }
|
||||
.filters input { flex:1; min-width:160px; }
|
||||
.filters input { flex:1; min-width:140px; }
|
||||
.wrap { overflow-x:auto; }
|
||||
table { width:100%; border-collapse:collapse; font-size:13px; }
|
||||
th, td { padding:9px 12px; text-align:right; border-bottom:1px solid #16305a; white-space:nowrap; }
|
||||
th { position:sticky; top:52px; background:#122c52; font-weight:600; z-index:1; }
|
||||
tr:hover { background:#132b50; }
|
||||
th { background:#122c52; font-weight:600; border-bottom:2px solid #1E5FA8; }
|
||||
tr.row { cursor:pointer; }
|
||||
tr.row:hover { background:#132b50; }
|
||||
.m { font-weight:700; font-size:11px; padding:2px 7px; border-radius:6px; }
|
||||
.GET{background:#134e4a;color:#5eead4} .POST{background:#1e3a8a;color:#93c5fd}
|
||||
.PUT{background:#713f12;color:#fcd34d} .DELETE{background:#7f1d1d;color:#fca5a5}
|
||||
.st { font-weight:700; }
|
||||
.s2{color:#4ade80} .s3{color:#60a5fa} .s4{color:#fbbf24} .s5{color:#f87171}
|
||||
.path { font-family: ui-monospace, monospace; }
|
||||
.body { display:none; }
|
||||
tr.open .body { display:table-row; }
|
||||
.body td { background:#0b1c39; white-space:pre-wrap; font-family: ui-monospace, monospace;
|
||||
font-size:12px; color:#fca5a5; direction:ltr; text-align:left; }
|
||||
.phone { font-family: ui-monospace, monospace; color:#fcd34d; }
|
||||
.slow { color:#fbbf24; }
|
||||
.dim { opacity:.55; }
|
||||
.empty { padding:40px; text-align:center; opacity:.6; }
|
||||
.detail td { background:#0b1c39; padding:0; }
|
||||
.detail.hidden { display:none; }
|
||||
.panels { padding:12px 16px; display:grid; gap:12px; }
|
||||
.panel h4 { margin:0 0 6px; font-size:12px; color:#8ab4e8; font-weight:700;
|
||||
display:flex; align-items:center; gap:6px; }
|
||||
.panel pre { margin:0; background:#04101f; border:1px solid #16305a; border-radius:8px;
|
||||
padding:10px; font-family: ui-monospace, monospace; font-size:12px; line-height:1.6;
|
||||
color:#cbd5e1; white-space:pre-wrap; word-break:break-word; direction:ltr; text-align:left;
|
||||
max-height:280px; overflow:auto; }
|
||||
.kv { display:flex; gap:8px; flex-wrap:wrap; font-size:12px; margin-bottom:4px; }
|
||||
.kv b { color:#8ab4e8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -51,6 +61,7 @@ const pageHTML = `<!doctype html>
|
||||
</header>
|
||||
<div class="filters">
|
||||
<input id="q" placeholder="جستجو در مسیر…">
|
||||
<input id="pf" placeholder="شماره موبایل…" inputmode="numeric">
|
||||
<select id="mf">
|
||||
<option value="">همه متدها</option>
|
||||
<option>GET</option><option>POST</option><option>PUT</option><option>DELETE</option>
|
||||
@@ -61,48 +72,68 @@ const pageHTML = `<!doctype html>
|
||||
<option value="4">4xx</option><option value="5">5xx</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>زمان</th><th>متد</th><th>مسیر</th><th>وضعیت</th><th>مدت</th><th>حجم</th><th>IP</th>
|
||||
<th>زمان</th><th>متد</th><th>مسیر</th><th>وضعیت</th><th>مدت</th><th>موبایل</th><th>IP</th>
|
||||
</tr></thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="empty" id="empty" style="display:none">درخواستی ثبت نشده است</div>
|
||||
<script>
|
||||
let data = [];
|
||||
let openId = null;
|
||||
const $ = s => document.querySelector(s);
|
||||
function fmtTime(t){ const d = new Date(t); return d.toLocaleTimeString('fa-IR'); }
|
||||
const esc = s => (s||'').replace(/&/g,'&').replace(/</g,'<');
|
||||
function fmtTime(t){ return new Date(t).toLocaleTimeString('fa-IR'); }
|
||||
function stClass(s){ return 's' + Math.floor(s/100); }
|
||||
|
||||
function detail(e){
|
||||
const sec = (title, body) => body
|
||||
? '<div class="panel"><h4>'+title+'</h4><pre>'+esc(body)+'</pre></div>' : '';
|
||||
const kv = '<div class="kv">'+
|
||||
(e.mobile ? '<span><b>موبایل:</b> '+esc(e.mobile)+'</span>' : '<span class="dim">کاربر مهمان</span>')+
|
||||
(e.user_id ? '<span><b>شناسه کاربر:</b> '+e.user_id+'</span>' : '')+
|
||||
'<span><b>حجم پاسخ:</b> '+e.bytes+' بایت</span>'+
|
||||
'<span><b>مدت:</b> '+e.duration_ms+'ms</span></div>';
|
||||
return '<div class="panels">'+kv+
|
||||
sec('هدرها', e.headers)+
|
||||
sec('Payload درخواست', e.req_body)+
|
||||
sec('پاسخ', e.resp_body)+
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function render(){
|
||||
const q = $('#q').value.trim().toLowerCase();
|
||||
const pf = $('#pf').value.trim();
|
||||
const mf = $('#mf').value, sf = $('#sf').value;
|
||||
const rows = data.filter(e =>
|
||||
(!q || (e.path||'').toLowerCase().includes(q)) &&
|
||||
(!pf || (e.mobile||'').includes(pf)) &&
|
||||
(!mf || e.method === mf) &&
|
||||
(!sf || Math.floor((e.status||0)/100) == sf));
|
||||
const tb = $('#rows'); tb.innerHTML = '';
|
||||
$('#empty').style.display = rows.length ? 'none' : 'block';
|
||||
for(const e of rows){
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'row';
|
||||
const slow = e.duration_ms > 500 ? ' slow' : '';
|
||||
tr.innerHTML =
|
||||
'<td class="dim">'+fmtTime(e.time)+'</td>'+
|
||||
'<td><span class="m '+e.method+'">'+e.method+'</span></td>'+
|
||||
'<td class="path">'+e.path+'</td>'+
|
||||
'<td class="path">'+esc(e.path)+'</td>'+
|
||||
'<td class="st '+stClass(e.status)+'">'+(e.status||'-')+'</td>'+
|
||||
'<td class="'+slow.trim()+'">'+e.duration_ms+'ms</td>'+
|
||||
'<td class="dim">'+e.bytes+'</td>'+
|
||||
'<td class="dim">'+(e.ip||'')+'</td>';
|
||||
if(e.body){
|
||||
tr.style.cursor='pointer';
|
||||
tr.onclick = () => { const b = tr.nextSibling; b.style.display = b.style.display==='table-row'?'none':'table-row'; };
|
||||
tb.appendChild(tr);
|
||||
const b = document.createElement('tr'); b.className='body';
|
||||
b.innerHTML = '<td colspan="7">'+e.body.replace(/</g,'<')+'</td>';
|
||||
b.style.display='none';
|
||||
tb.appendChild(b);
|
||||
} else {
|
||||
tb.appendChild(tr);
|
||||
'<td class="phone">'+esc(e.mobile||'')+'</td>'+
|
||||
'<td class="dim">'+esc(e.ip||'')+'</td>';
|
||||
tr.onclick = () => { openId = (openId===e.id ? null : e.id); render(); };
|
||||
tb.appendChild(tr);
|
||||
if(openId === e.id){
|
||||
const dr = document.createElement('tr');
|
||||
dr.className = 'detail';
|
||||
dr.innerHTML = '<td colspan="7">'+detail(e)+'</td>';
|
||||
tb.appendChild(dr);
|
||||
}
|
||||
}
|
||||
$('#meta').textContent = rows.length + ' / ' + data.length + ' درخواست';
|
||||
@@ -114,7 +145,7 @@ async function load(){
|
||||
render();
|
||||
}catch(e){ $('#meta').textContent = 'خطا در دریافت'; }
|
||||
}
|
||||
['q','mf','sf'].forEach(id => $('#'+id).addEventListener('input', render));
|
||||
['q','pf','mf','sf'].forEach(id => $('#'+id).addEventListener('input', render));
|
||||
load();
|
||||
setInterval(() => { if($('#live').checked) load(); }, 2000);
|
||||
</script>
|
||||
|
||||
+138
-29
@@ -1,7 +1,7 @@
|
||||
// Package telescope یک بازرسِ سبکِ درخواستها است (شبیهِ Laravel Telescope).
|
||||
// آخرین N درخواست را در یک رینگبافرِ حافظه نگه میدارد: متد، مسیر، وضعیت،
|
||||
// مدتزمان، IP و در صورتِ خطا (>=۴۰۰) بدنهی درخواست. بدونِ هیچ وابستگیِ خارجی
|
||||
// و با سرباری ناچیز؛ برای پایشِ سریع روی سرورِ کممنابع مناسب است.
|
||||
// مدتزمان، IP، هدرها، payloadِ درخواست، پاسخ و شمارهی موبایلِ کاربر. بدونِ
|
||||
// وابستگیِ خارجی و با سرباری ناچیز؛ برای پایشِ سریع روی سرورِ کممنابع مناسب است.
|
||||
package telescope
|
||||
|
||||
import (
|
||||
@@ -11,11 +11,21 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
maxReqBody = 8 << 10 // ۸ کیلوبایت از payloadِ درخواست (فقط برای نمایش)
|
||||
maxRespBody = 16 << 10 // ۱۶ کیلوبایت از پاسخ
|
||||
// سقفِ خواندنِ بدنهی غیرِ multipart (JSON): کامل بازگردانده میشود تا هندلر
|
||||
// همه را ببیند؛ فقط برای امنیت در برابرِ بدنهی بسیار بزرگ محدود شده است.
|
||||
maxCaptureBody = 4 << 20 // ۴ مگابایت
|
||||
)
|
||||
|
||||
// Entry یک درخواستِ ثبتشده را نگه میدارد.
|
||||
type Entry struct {
|
||||
ID int64 `json:"id"`
|
||||
@@ -26,7 +36,11 @@ type Entry struct {
|
||||
Duration int64 `json:"duration_ms"`
|
||||
IP string `json:"ip"`
|
||||
Bytes int `json:"bytes"`
|
||||
Body string `json:"body,omitempty"` // فقط برای خطاها (>=۴۰۰) و POST/PUT
|
||||
UserID int64 `json:"user_id,omitempty"`
|
||||
Mobile string `json:"mobile,omitempty"` // در زمانِ نمایش resolve میشود
|
||||
Headers string `json:"headers,omitempty"`
|
||||
ReqBody string `json:"req_body,omitempty"` // payloadِ درخواست
|
||||
RespBody string `json:"resp_body,omitempty"` // بدنهی پاسخ
|
||||
}
|
||||
|
||||
// Telescope رینگبافرِ همزمانامنِ درخواستها.
|
||||
@@ -37,14 +51,27 @@ type Telescope struct {
|
||||
next int64 // شناسهی افزایشی
|
||||
full bool
|
||||
head int // اندیسِ نوشتنِ بعدی
|
||||
|
||||
// decodeUID شناسهی کاربر را از توکنِ درخواست (بدونِ DB، فقط HMAC) درمیآورد.
|
||||
decodeUID func(*http.Request) int64
|
||||
// resolveMobile شمارهی موبایل را از شناسه (با DB) میگیرد؛ فقط در زمانِ نمایش.
|
||||
resolveMobile func(int64) string
|
||||
mobileCache map[int64]string
|
||||
}
|
||||
|
||||
// New یک بازرس با ظرفیتِ size میسازد (حداقل ۱).
|
||||
func New(size int) *Telescope {
|
||||
// New یک بازرس با ظرفیتِ size میسازد (حداقل ۱). decodeUID و resolveMobile
|
||||
// میتوانند nil باشند (مثلاً در تستها).
|
||||
func New(size int, decodeUID func(*http.Request) int64, resolveMobile func(int64) string) *Telescope {
|
||||
if size < 1 {
|
||||
size = 200
|
||||
}
|
||||
return &Telescope{buf: make([]Entry, size), size: size}
|
||||
return &Telescope{
|
||||
buf: make([]Entry, size),
|
||||
size: size,
|
||||
decodeUID: decodeUID,
|
||||
resolveMobile: resolveMobile,
|
||||
mobileCache: map[int64]string{},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Telescope) add(e Entry) {
|
||||
@@ -59,28 +86,55 @@ func (t *Telescope) add(e Entry) {
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
// Entries آخرین درخواستها را بهترتیبِ جدید-به-قدیم برمیگرداند.
|
||||
// Entries آخرین درخواستها را بهترتیبِ جدید-به-قدیم برمیگرداند و در همینجا
|
||||
// شمارهی موبایلِ هر کاربر را (با کش) resolve میکند.
|
||||
func (t *Telescope) Entries() []Entry {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
n := t.head
|
||||
if t.full {
|
||||
n = t.size
|
||||
}
|
||||
out := make([]Entry, 0, n)
|
||||
// از جدیدترین (درست قبلِ head) عقب میرویم.
|
||||
for i := 0; i < n; i++ {
|
||||
idx := (t.head - 1 - i + t.size*2) % t.size
|
||||
out = append(out, t.buf[idx])
|
||||
}
|
||||
t.mu.Unlock()
|
||||
|
||||
// resolveِ موبایل بیرون از قفلِ اصلی (ممکن است به DB بزند).
|
||||
for i := range out {
|
||||
out[i].Mobile = t.mobileFor(out[i].UserID)
|
||||
// درخواستهای ورود (بدونِ توکن) شماره را در payload دارند.
|
||||
if out[i].Mobile == "" {
|
||||
out[i].Mobile = mobileFromBody(out[i].ReqBody)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// statusRecorder کدِ وضعیت و حجمِ پاسخ را میگیرد.
|
||||
func (t *Telescope) mobileFor(id int64) string {
|
||||
if id == 0 || t.resolveMobile == nil {
|
||||
return ""
|
||||
}
|
||||
t.mu.Lock()
|
||||
if m, ok := t.mobileCache[id]; ok {
|
||||
t.mu.Unlock()
|
||||
return m
|
||||
}
|
||||
t.mu.Unlock()
|
||||
m := t.resolveMobile(id)
|
||||
t.mu.Lock()
|
||||
t.mobileCache[id] = m
|
||||
t.mu.Unlock()
|
||||
return m
|
||||
}
|
||||
|
||||
// statusRecorder کدِ وضعیت، حجم و بخشی از بدنهی پاسخ را میگیرد.
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
bytes int
|
||||
buf *bytes.Buffer
|
||||
}
|
||||
|
||||
func (r *statusRecorder) WriteHeader(code int) {
|
||||
@@ -92,6 +146,14 @@ func (r *statusRecorder) Write(b []byte) (int, error) {
|
||||
if r.status == 0 {
|
||||
r.status = http.StatusOK
|
||||
}
|
||||
if r.buf != nil && r.buf.Len() < maxRespBody {
|
||||
remain := maxRespBody - r.buf.Len()
|
||||
if remain >= len(b) {
|
||||
r.buf.Write(b)
|
||||
} else {
|
||||
r.buf.Write(b[:remain])
|
||||
}
|
||||
}
|
||||
n, err := r.ResponseWriter.Write(b)
|
||||
r.bytes += n
|
||||
return n, err
|
||||
@@ -105,7 +167,8 @@ func (r *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
return nil, nil, http.ErrNotSupported
|
||||
}
|
||||
|
||||
// Middleware هر درخواستِ /api و /admin را ثبت میکند (بهجز خودِ صفحهی تلسکوپ).
|
||||
// Middleware هر درخواستِ /api و /admin را ثبت میکند (بهجز خودِ صفحهی تلسکوپ،
|
||||
// WebSocket و فایلهای استاتیک).
|
||||
func (t *Telescope) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
p := req.URL.Path
|
||||
@@ -115,19 +178,36 @@ func (t *Telescope) Middleware(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
var body string
|
||||
if (req.Method == http.MethodPost || req.Method == http.MethodPut) && req.Body != nil {
|
||||
b, _ := io.ReadAll(io.LimitReader(req.Body, 4096))
|
||||
req.Body = io.NopCloser(bytes.NewReader(b))
|
||||
body = maskSecrets(string(b))
|
||||
var reqBody string
|
||||
if req.Body != nil && req.Method != http.MethodGet {
|
||||
// آپلودِ فایل (multipart) را دست نزن؛ بدنه بزرگ است و باید کامل به
|
||||
// هندلر برسد (وگرنه ParseMultipartForm شکست میخورد ⇒ «bad upload»).
|
||||
if strings.HasPrefix(req.Header.Get("Content-Type"), "multipart/form-data") {
|
||||
reqBody = "[multipart upload]"
|
||||
} else {
|
||||
// بدنه را کامل بخوان و کامل بازگردان (تا هندلر همهی آن را ببیند)،
|
||||
// اما فقط بخشِ ابتدایی را برای نمایش در تلسکوپ نگهدار.
|
||||
b, _ := io.ReadAll(io.LimitReader(req.Body, maxCaptureBody))
|
||||
req.Body = io.NopCloser(bytes.NewReader(b))
|
||||
n := len(b)
|
||||
if n > maxReqBody {
|
||||
n = maxReqBody
|
||||
}
|
||||
reqBody = maskSecrets(string(b[:n]))
|
||||
}
|
||||
}
|
||||
|
||||
rec := &statusRecorder{ResponseWriter: w}
|
||||
var uid int64
|
||||
if t.decodeUID != nil {
|
||||
uid = t.decodeUID(req) // فقط HMAC، بدون DB
|
||||
}
|
||||
|
||||
rec := &statusRecorder{ResponseWriter: w, buf: &bytes.Buffer{}}
|
||||
start := time.Now()
|
||||
next.ServeHTTP(rec, req)
|
||||
dur := time.Since(start)
|
||||
|
||||
e := Entry{
|
||||
t.add(Entry{
|
||||
Time: start,
|
||||
Method: req.Method,
|
||||
Path: p,
|
||||
@@ -135,18 +215,49 @@ func (t *Telescope) Middleware(next http.Handler) http.Handler {
|
||||
Duration: dur.Milliseconds(),
|
||||
IP: req.RemoteAddr,
|
||||
Bytes: rec.bytes,
|
||||
}
|
||||
// بدنه را فقط برای خطاها نگه میداریم تا حافظه/حریمِ خصوصی حفظ شود.
|
||||
if rec.status >= 400 {
|
||||
e.Body = body
|
||||
}
|
||||
t.add(e)
|
||||
UserID: uid,
|
||||
Headers: formatHeaders(req.Header),
|
||||
ReqBody: reqBody,
|
||||
RespBody: maskSecrets(rec.buf.String()),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// maskSecrets مقادیرِ حساس (توکن، کد، پسورد) را در بدنهی JSON پنهان میکند.
|
||||
// formatHeaders هدرهای درخواست را (با پنهانکردنِ مقادیرِ حساس) به رشته تبدیل میکند.
|
||||
func formatHeaders(h http.Header) string {
|
||||
keys := make([]string, 0, len(h))
|
||||
for k := range h {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var sb strings.Builder
|
||||
for _, k := range keys {
|
||||
val := strings.Join(h[k], ", ")
|
||||
switch strings.ToLower(k) {
|
||||
case "authorization", "cookie", "x-access-token":
|
||||
val = "***"
|
||||
}
|
||||
sb.WriteString(k)
|
||||
sb.WriteString(": ")
|
||||
sb.WriteString(val)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
var mobileRe = regexp.MustCompile(`"mobile"\s*:\s*"([^"]+)"`)
|
||||
|
||||
// mobileFromBody شمارهی موبایل را از payloadِ درخواست (مثلِ ورود) درمیآورد.
|
||||
func mobileFromBody(body string) string {
|
||||
if m := mobileRe.FindStringSubmatch(body); len(m) == 2 {
|
||||
return m[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// maskSecrets مقادیرِ حساس (توکن، کد، پسورد، امضا) را در JSON پنهان میکند.
|
||||
func maskSecrets(s string) string {
|
||||
for _, k := range []string{"password", "token", "code", "otp", "signature"} {
|
||||
for _, k := range []string{"password", "token", "code", "otp", "signature", "purchase_data"} {
|
||||
s = maskField(s, k)
|
||||
}
|
||||
return s
|
||||
@@ -158,7 +269,6 @@ func maskField(s, key string) string {
|
||||
if i < 0 {
|
||||
return s
|
||||
}
|
||||
// دنبالِ ": " و سپس مقدار میگردیم.
|
||||
j := strings.Index(s[i:], ":")
|
||||
if j < 0 {
|
||||
return s
|
||||
@@ -168,8 +278,7 @@ func maskField(s, key string) string {
|
||||
if strings.HasPrefix(rest, `"`) {
|
||||
end := strings.Index(rest[1:], `"`)
|
||||
if end >= 0 {
|
||||
masked := s[:i+j+1] + `"***"` + rest[end+2:]
|
||||
return masked
|
||||
return s[:i+j+1] + `"***"` + rest[end+2:]
|
||||
}
|
||||
}
|
||||
return s
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
func TestRingOrderAndOverflow(t *testing.T) {
|
||||
tel := New(3)
|
||||
tel := New(3, nil, nil)
|
||||
for i := 0; i < 5; i++ {
|
||||
tel.add(Entry{Path: string(rune('a' + i))})
|
||||
}
|
||||
@@ -29,43 +29,76 @@ func TestRingOrderAndOverflow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareCapturesErrorBodyMasked(t *testing.T) {
|
||||
tel := New(10)
|
||||
func TestMiddlewareCapturesPayloadHeadersResponseMasked(t *testing.T) {
|
||||
tel := New(10, nil, nil)
|
||||
h := tel.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"token":"xyz","message":"bad"}`))
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/x",
|
||||
strings.NewReader(`{"password":"secret","kind":"coin"}`))
|
||||
req.Header.Set("Authorization", "Bearer supersecret")
|
||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
e := tel.Entries()
|
||||
if len(e) != 1 || e[0].Status != 400 {
|
||||
t.Fatalf("expected one 400 entry, got %+v", e)
|
||||
}
|
||||
if !strings.Contains(e[0].Body, `"password":"***"`) {
|
||||
t.Errorf("password not masked: %s", e[0].Body)
|
||||
// payload برای هر درخواست ثبت میشود و مقادیرِ حساس ماسک میشوند.
|
||||
if !strings.Contains(e[0].ReqBody, `"password":"***"`) {
|
||||
t.Errorf("password not masked: %s", e[0].ReqBody)
|
||||
}
|
||||
if !strings.Contains(e[0].Body, `"kind":"coin"`) {
|
||||
t.Errorf("non-secret field lost: %s", e[0].Body)
|
||||
if !strings.Contains(e[0].ReqBody, `"kind":"coin"`) {
|
||||
t.Errorf("non-secret field lost: %s", e[0].ReqBody)
|
||||
}
|
||||
// پاسخ ثبت و توکنِ آن ماسک میشود.
|
||||
if !strings.Contains(e[0].RespBody, `"token":"***"`) {
|
||||
t.Errorf("response token not masked: %s", e[0].RespBody)
|
||||
}
|
||||
// هدرِ Authorization در نمایش پنهان میشود.
|
||||
if strings.Contains(e[0].Headers, "supersecret") {
|
||||
t.Errorf("authorization header leaked: %s", e[0].Headers)
|
||||
}
|
||||
if !strings.Contains(e[0].Headers, "Authorization: ***") {
|
||||
t.Errorf("authorization header not masked: %s", e[0].Headers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareOKHasNoBody(t *testing.T) {
|
||||
tel := New(10)
|
||||
func TestMobileFromLoginBody(t *testing.T) {
|
||||
tel := New(10, nil, nil)
|
||||
h := tel.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/y",
|
||||
strings.NewReader(`{"token":"abc"}`))
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/login-otp",
|
||||
strings.NewReader(`{"mobile":"09120000000"}`))
|
||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||
e := tel.Entries()
|
||||
if len(e) != 1 || e[0].Body != "" {
|
||||
t.Errorf("2xx should not store body, got %+v", e)
|
||||
if len(e) != 1 || e[0].Mobile != "09120000000" {
|
||||
t.Errorf("mobile not extracted from login body, got %+v", e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMobileByUserID(t *testing.T) {
|
||||
tel := New(10,
|
||||
func(*http.Request) int64 { return 42 },
|
||||
func(id int64) string {
|
||||
if id == 42 {
|
||||
return "09121112233"
|
||||
}
|
||||
return ""
|
||||
})
|
||||
h := tel.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/me", nil))
|
||||
e := tel.Entries()
|
||||
if len(e) != 1 || e[0].Mobile != "09121112233" || e[0].UserID != 42 {
|
||||
t.Errorf("mobile not resolved by user id, got %+v", e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareSkipsWS(t *testing.T) {
|
||||
tel := New(10)
|
||||
tel := New(10, nil, nil)
|
||||
h := tel.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
@@ -42,9 +42,9 @@ func (r *Repo) FindByID(ctx context.Context, id int64) (*User, error) {
|
||||
return scan(row)
|
||||
}
|
||||
|
||||
// Create کاربر جدید میسازد.
|
||||
// Create کاربر جدید میسازد (با هدیهی خوشآمدِ ۱۰۰ سکه).
|
||||
func (r *Repo) Create(ctx context.Context, mobile string) (*User, error) {
|
||||
res, err := r.db.ExecContext(ctx, `INSERT INTO users (mobile) VALUES (?)`, mobile)
|
||||
res, err := r.db.ExecContext(ctx, `INSERT INTO users (mobile, coins) VALUES (?, 100)`, mobile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
package ws
|
||||
|
||||
import "hakemsho/internal/game"
|
||||
|
||||
// هوشِ باتِ حکم — تصمیمگیریِ اکتشافی (heuristic) که فقط از دستِ خودِ بات و
|
||||
// کارتهای روی زمین استفاده میکند (بدونِ دیدنِ دستِ دیگران؛ بازیِ منصفانه).
|
||||
//
|
||||
// راهبردها:
|
||||
// - انتخابِ حکم: خالی که هم بلند است و هم کارتِ قوی دارد.
|
||||
// - رهبریِ دست: آسِ غیرِحکم را نقد کن؛ وگرنه از بلندترین خالِ غیرِحکم کارتِ کم بریز.
|
||||
// - دنبالکردن: اگر شریک میبرد کارتِ کم بریز؛ اگر حریف میبرد با کمترین کارتِ
|
||||
// برنده ببر؛ اگر خالِ زمینه را نداری و حریف میبرد با کمترین حکم بِبُر.
|
||||
|
||||
// botChooseTrump خالِ حکم را بر اساسِ طول + قدرتِ کارتها انتخاب میکند.
|
||||
func botChooseTrump(hand []game.Card) game.Suit {
|
||||
var score [4]float64
|
||||
for _, c := range hand {
|
||||
score[c.Suit] += 1
|
||||
switch c.Rank {
|
||||
case game.Ace:
|
||||
score[c.Suit] += 2.0
|
||||
case game.King:
|
||||
score[c.Suit] += 1.3
|
||||
case game.Queen:
|
||||
score[c.Suit] += 0.7
|
||||
case game.Jack:
|
||||
score[c.Suit] += 0.3
|
||||
}
|
||||
}
|
||||
best, bestScore := game.Suit(0), -1.0
|
||||
for s := game.Suit(0); s < 4; s++ {
|
||||
if score[s] > bestScore {
|
||||
bestScore, best = score[s], s
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// botPlay کارتی که بات باید بازی کند را برمیگرداند. seen حافظهی کارتهای
|
||||
// بازیشدهی این هَند است (کارتشماری برای تصمیمِ بهتر).
|
||||
func botPlay(hand []game.Card, trick []game.TrickCard, trump, lead game.Suit, seen [4]uint16) game.Card {
|
||||
if len(trick) == 0 {
|
||||
return botLead(hand, trump, seen)
|
||||
}
|
||||
|
||||
best := trickBest(trick, trump, lead)
|
||||
// جایگاهها متناوباند؛ کارتی که دو نوبت قبل انداخته شده متعلق به شریکِ باتِ
|
||||
// در نوبت است. اگر آن (یا هر کارتِ شریک) بهترین کارتِ فعلی باشد، شریک میبرد.
|
||||
partnerWins := false
|
||||
if len(trick) >= 2 {
|
||||
partnerWins = best.Seat == trick[len(trick)-2].Seat
|
||||
}
|
||||
|
||||
followers := cardsOfSuit(hand, lead)
|
||||
if len(followers) > 0 {
|
||||
if partnerWins {
|
||||
return lowest(followers) // شریک میبرد ⇒ کارتِ کم بریز
|
||||
}
|
||||
if w, ok := lowestWinner(followers, best.Card, trump, lead); ok {
|
||||
return w // با کمترین کارتِ برنده ببر
|
||||
}
|
||||
return lowest(followers) // نمیتوانی ببری ⇒ کارتِ کم
|
||||
}
|
||||
|
||||
// خالِ زمینه را نداریم (void).
|
||||
trumps := cardsOfSuit(hand, trump)
|
||||
if partnerWins || len(trumps) == 0 {
|
||||
return lowestDiscard(hand, trump)
|
||||
}
|
||||
if w, ok := lowestWinner(trumps, best.Card, trump, lead); ok {
|
||||
return w // با کمترین حکمِ برنده بِبُر
|
||||
}
|
||||
return lowestDiscard(hand, trump) // حکمت هم نمیبرد ⇒ دورریز
|
||||
}
|
||||
|
||||
// botLead راهبردِ رهبریِ دست با کارتشماری:
|
||||
// ۱. کارتِ «برندهی قطعی» (بالاترین کارتِ باقیماندهی یک خالِ غیرِحکم) را نقد کن.
|
||||
// ۲. اگر کنترلِ حکم داری (بالاترین حکمِ باقیمانده + ۳ حکم یا بیشتر) حکم بکِش.
|
||||
// ۳. وگرنه از کوتاهترین خالِ غیرِحکم کارتِ کم بریز تا زود void شوی و بعداً بِبُری
|
||||
// (اگر حکم داری)؛ در نبودِ حکم از بلندترین خال کارتِ کم.
|
||||
func botLead(hand []game.Card, trump game.Suit, seen [4]uint16) game.Card {
|
||||
counts := suitCounts(hand)
|
||||
|
||||
// ۱. برندهی قطعی: کارتی از خالِ غیرِحکم که هیچ کارتِ بالاتری از آن بیرون نمانده.
|
||||
var boss *game.Card
|
||||
for i := range hand {
|
||||
c := hand[i]
|
||||
if c.Suit == trump {
|
||||
continue
|
||||
}
|
||||
if higherOut(seen, hand, c.Suit, c.Rank) == 0 {
|
||||
if boss == nil || counts[c.Suit] > counts[boss.Suit] ||
|
||||
(counts[c.Suit] == counts[boss.Suit] && c.Rank > boss.Rank) {
|
||||
cc := c
|
||||
boss = &cc
|
||||
}
|
||||
}
|
||||
}
|
||||
if boss != nil {
|
||||
return *boss
|
||||
}
|
||||
|
||||
// ۲. کشیدنِ حکم: اگر بالاترین حکمِ باقیمانده دستِ ماست و حکمِ زیاد داریم.
|
||||
trumps := cardsOfSuit(hand, trump)
|
||||
if len(trumps) >= 3 {
|
||||
top := highest(trumps)
|
||||
if higherOut(seen, hand, trump, top.Rank) == 0 {
|
||||
return top
|
||||
}
|
||||
}
|
||||
|
||||
// ۳. کوتاهترین خالِ غیرِحکم (برای void شدن) اگر حکم داریم؛ وگرنه بلندترین.
|
||||
haveTrump := len(trumps) > 0
|
||||
bestSuit, bestLen := game.Suit(255), -1
|
||||
for s := game.Suit(0); s < 4; s++ {
|
||||
if s == trump || counts[s] == 0 {
|
||||
continue
|
||||
}
|
||||
better := false
|
||||
if bestLen < 0 {
|
||||
better = true
|
||||
} else if haveTrump {
|
||||
better = counts[s] < bestLen // کوتاهترین
|
||||
} else {
|
||||
better = counts[s] > bestLen // بلندترین
|
||||
}
|
||||
if better {
|
||||
bestLen, bestSuit = counts[s], s
|
||||
}
|
||||
}
|
||||
if bestSuit != game.Suit(255) {
|
||||
return lowest(cardsOfSuit(hand, bestSuit))
|
||||
}
|
||||
// فقط حکم مانده ⇒ بالاترین حکم را بزن.
|
||||
return highest(trumps)
|
||||
}
|
||||
|
||||
// suitCounts تعدادِ کارتِ هر خال در دست.
|
||||
func suitCounts(hand []game.Card) [4]int {
|
||||
var c [4]int
|
||||
for _, x := range hand {
|
||||
c[x.Suit]++
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// higherOut تعدادِ کارتهای همخالِ بالاتر از rank که هنوز بازی نشدهاند و در دستِ
|
||||
// خودِ بات هم نیستند (یعنی دستِ حریف/شریکاند). ۰ یعنی این کارت «برندهی خال» است.
|
||||
func higherOut(seen [4]uint16, hand []game.Card, suit game.Suit, rank game.Rank) int {
|
||||
n := 0
|
||||
for r := rank + 1; r <= game.Ace; r++ {
|
||||
if seen[suit]&(1<<(r-2)) != 0 {
|
||||
continue // بازی شده
|
||||
}
|
||||
held := false
|
||||
for _, c := range hand {
|
||||
if c.Suit == suit && c.Rank == r {
|
||||
held = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !held {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// --- کمکیها ---
|
||||
|
||||
func trickBest(trick []game.TrickCard, trump, lead game.Suit) game.TrickCard {
|
||||
best := trick[0]
|
||||
for _, tc := range trick[1:] {
|
||||
if game.Beats(tc.Card, best.Card, trump, lead) {
|
||||
best = tc
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func cardsOfSuit(hand []game.Card, s game.Suit) []game.Card {
|
||||
var out []game.Card
|
||||
for _, c := range hand {
|
||||
if c.Suit == s {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func lowest(cards []game.Card) game.Card {
|
||||
best := cards[0]
|
||||
for _, c := range cards[1:] {
|
||||
if c.Rank < best.Rank {
|
||||
best = c
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func highest(cards []game.Card) game.Card {
|
||||
best := cards[0]
|
||||
for _, c := range cards[1:] {
|
||||
if c.Rank > best.Rank {
|
||||
best = c
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// lowestWinner کمترین کارتِ candidate که کارتِ فعلیِ برنده را میبرد.
|
||||
func lowestWinner(candidates []game.Card, cur game.Card, trump, lead game.Suit) (game.Card, bool) {
|
||||
var best game.Card
|
||||
found := false
|
||||
for _, c := range candidates {
|
||||
if game.Beats(c, cur, trump, lead) && (!found || c.Rank < best.Rank) {
|
||||
best, found = c, true
|
||||
}
|
||||
}
|
||||
return best, found
|
||||
}
|
||||
|
||||
// lowestDiscard کمترین کارتِ غیرِحکم را برای دورریز برمیگرداند؛ اگر فقط حکم مانده،
|
||||
// کمترین حکم.
|
||||
func lowestDiscard(hand []game.Card, trump game.Suit) game.Card {
|
||||
var nonTrump []game.Card
|
||||
for _, c := range hand {
|
||||
if c.Suit != trump {
|
||||
nonTrump = append(nonTrump, c)
|
||||
}
|
||||
}
|
||||
if len(nonTrump) > 0 {
|
||||
return lowest(nonTrump)
|
||||
}
|
||||
return lowest(hand)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hakemsho/internal/game"
|
||||
)
|
||||
|
||||
func card(s game.Suit, r game.Rank) game.Card { return game.Card{Suit: s, Rank: r} }
|
||||
|
||||
func TestBotWinsWhenOpponentLeads(t *testing.T) {
|
||||
// حریف با شاهِ پیک رهبری کرده؛ بات باید با آسِ پیک (کمترین کارتِ برنده) ببرد.
|
||||
hand := []game.Card{card(game.Spades, game.Ace), card(game.Spades, 3), card(game.Clubs, 5)}
|
||||
trick := []game.TrickCard{{Seat: 0, Card: card(game.Spades, game.King)}}
|
||||
got := botPlay(hand, trick, game.Hearts, game.Spades, [4]uint16{})
|
||||
if got != card(game.Spades, game.Ace) {
|
||||
t.Errorf("expected S-A to win, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotSavesCardWhenPartnerWins(t *testing.T) {
|
||||
// شریک (صندلی۰) با آسِ پیک برنده است؛ باتِ صندلی۲ باید کمترین پیک را بریزد.
|
||||
hand := []game.Card{card(game.Spades, game.King), card(game.Spades, 5)}
|
||||
trick := []game.TrickCard{
|
||||
{Seat: 0, Card: card(game.Spades, game.Ace)},
|
||||
{Seat: 1, Card: card(game.Spades, 2)},
|
||||
}
|
||||
got := botPlay(hand, trick, game.Hearts, game.Spades, [4]uint16{})
|
||||
if got != card(game.Spades, 5) {
|
||||
t.Errorf("expected low S-5 (save the King), got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotTrumpsOpponent(t *testing.T) {
|
||||
// بات پیک ندارد و حریف با آسِ پیک میبرد ⇒ باید با کمترین حکم بِبُرد.
|
||||
hand := []game.Card{card(game.Hearts, 2), card(game.Hearts, game.King), card(game.Clubs, 9)}
|
||||
trick := []game.TrickCard{{Seat: 0, Card: card(game.Spades, game.Ace)}}
|
||||
got := botPlay(hand, trick, game.Hearts, game.Spades, [4]uint16{})
|
||||
if got != card(game.Hearts, 2) {
|
||||
t.Errorf("expected lowest trump H-2, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotDiscardsWhenPartnerWinsAndVoid(t *testing.T) {
|
||||
// شریک میبرد و بات خالِ زمینه را ندارد ⇒ حکم را هدر ندهد، کارتِ کم بریزد.
|
||||
hand := []game.Card{card(game.Hearts, game.King), card(game.Clubs, 4), card(game.Clubs, 9)}
|
||||
trick := []game.TrickCard{
|
||||
{Seat: 0, Card: card(game.Spades, game.Ace)},
|
||||
{Seat: 1, Card: card(game.Spades, 2)},
|
||||
}
|
||||
got := botPlay(hand, trick, game.Hearts, game.Spades, [4]uint16{})
|
||||
if got != card(game.Clubs, 4) {
|
||||
t.Errorf("expected low discard C-4 (keep trump), got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotLeadsNonTrumpAce(t *testing.T) {
|
||||
// در رهبری، آسِ غیرِحکم را نقد کن.
|
||||
hand := []game.Card{card(game.Clubs, game.Ace), card(game.Diamonds, 7), card(game.Hearts, game.King)}
|
||||
got := botPlay(hand, nil, game.Hearts, 0, [4]uint16{})
|
||||
if got != card(game.Clubs, game.Ace) {
|
||||
t.Errorf("expected to lead C-A, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotLeadsBossWhenAceGone(t *testing.T) {
|
||||
// آسِ گشنیز قبلاً بازی شده ⇒ شاهِ گشنیز حالا برندهی قطعی است؛ باید آن را نقد کند
|
||||
// (نه کارتِ کم). این رفتار فقط با کارتشماری ممکن است.
|
||||
hand := []game.Card{card(game.Clubs, game.King), card(game.Clubs, 4), card(game.Diamonds, 2)}
|
||||
var seen [4]uint16
|
||||
seen[game.Clubs] |= 1 << (game.Ace - 2) // آسِ گشنیز رو شده
|
||||
got := botPlay(hand, nil, game.Hearts, 0, seen)
|
||||
if got != card(game.Clubs, game.King) {
|
||||
t.Errorf("expected to cash boss C-K (Ace gone), got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotCannotBeatKeepsLow(t *testing.T) {
|
||||
// حریف با آسِ پیک میبرد و بات فقط پیکِ پایین دارد ⇒ کمترین را بریزد.
|
||||
hand := []game.Card{card(game.Spades, 4), card(game.Spades, 9)}
|
||||
trick := []game.TrickCard{{Seat: 0, Card: card(game.Spades, game.Ace)}}
|
||||
got := botPlay(hand, trick, game.Hearts, game.Spades, [4]uint16{})
|
||||
if got != card(game.Spades, 4) {
|
||||
t.Errorf("expected low S-4, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotChooseTrumpPrefersStrongLongSuit(t *testing.T) {
|
||||
// خشت: ۴ کارت اما ضعیف؛ گشنیز: ۳ کارت اما آس+شاه. باید گشنیز را حکم کند اگر
|
||||
// امتیازش بیشتر شود — اینجا خشت ۴تایی برنده است. تستِ ساده: بلندترینِ قوی.
|
||||
hand := []game.Card{
|
||||
card(game.Diamonds, 2), card(game.Diamonds, 3), card(game.Diamonds, 4), card(game.Diamonds, 5),
|
||||
card(game.Clubs, game.Ace),
|
||||
}
|
||||
if got := botChooseTrump(hand); got != game.Diamonds {
|
||||
t.Errorf("expected Diamonds (longest), got %v", got)
|
||||
}
|
||||
}
|
||||
+24
-7
@@ -14,7 +14,9 @@ const (
|
||||
pongWait = 60 * time.Second
|
||||
pingPeriod = (pongWait * 9) / 10
|
||||
maxMessageSize = 4096
|
||||
sendBuffer = 32
|
||||
// بافرِ بزرگتر تا در سرورِ تکهستهای، هجومِ کوتاهِ وضعیتها (پخشِ کارت + چند حرکتِ
|
||||
// بات) روی کلاینتِ کند باعثِ دورریختنِ پیام نشود.
|
||||
sendBuffer = 256
|
||||
)
|
||||
|
||||
// Client یک اتصال WebSocket یک بازیکن.
|
||||
@@ -96,14 +98,29 @@ func (c *Client) writePump() {
|
||||
}
|
||||
}
|
||||
|
||||
// trySend ارسال غیرمسدودکننده؛ اگر اتصال بسته یا بافر پر بود پیام را میاندازد.
|
||||
// trySend ارسالِ غیرمسدودکننده. اگر بافر پر باشد، بهجای دورریختنِ پیامِ جدید،
|
||||
// «قدیمیترین» پیام دور ریخته میشود تا کلاینت روی جدیدترین وضعیت همگرا بماند
|
||||
// (هر وضعیتِ کامل، وضعیتِ قبلی را باطل میکند). قبلاً جدیدترین دور ریخته میشد و
|
||||
// کلاینت روی حالتِ کهنه گیر میکرد: کارتهای دست دیده نمیشد ولی باتها بازی میکردند.
|
||||
// send هیچگاه close نمیشود؛ پس از done صرفاً پیام دور ریخته میشود (بدون panic).
|
||||
func (c *Client) trySend(b []byte) {
|
||||
select {
|
||||
case <-c.done:
|
||||
case c.send <- b:
|
||||
default:
|
||||
slog.Warn("client send buffer full, dropping message", "user", c.UserID)
|
||||
for {
|
||||
select {
|
||||
case <-c.done:
|
||||
return
|
||||
case c.send <- b:
|
||||
return
|
||||
default:
|
||||
// بافر پر است: یک پیامِ قدیمی را خالی کن و دوباره تلاش کن.
|
||||
select {
|
||||
case <-c.send:
|
||||
slog.Warn("client send buffer full, dropping oldest", "user", c.UserID)
|
||||
case <-c.done:
|
||||
return
|
||||
default:
|
||||
// بافر بین دو تلاش خالی شد (تولیدکنندهی دیگر آن را خالی کرد)؛ ادامه بده.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-5
@@ -391,12 +391,14 @@ func (h *Hub) removeFromTable(t *pendingTable, c *Client) {
|
||||
func (h *Hub) broadcastLobby(t *pendingTable) {
|
||||
players := make([]lobbyPlayer, len(t.clients))
|
||||
for i, cl := range t.clients {
|
||||
_, _, avatar := h.settler.PlayerProfile(cl.UserID)
|
||||
_, _, avatar, frame, avatarImg := h.settler.PlayerProfile(cl.UserID)
|
||||
players[i] = lobbyPlayer{
|
||||
Name: cl.Name,
|
||||
Avatar: avatar,
|
||||
Host: cl.UserID == t.host,
|
||||
Seat: t.seatOf(i),
|
||||
Name: cl.Name,
|
||||
Avatar: avatar,
|
||||
Frame: frame,
|
||||
AvatarImg: avatarImg,
|
||||
Host: cl.UserID == t.host,
|
||||
Seat: t.seatOf(i),
|
||||
}
|
||||
}
|
||||
for i, cl := range t.clients {
|
||||
|
||||
@@ -27,9 +27,11 @@ type chatMsg struct {
|
||||
|
||||
// lobbyPlayer یک بازیکن در اتاق انتظارِ میز خصوصی.
|
||||
type lobbyPlayer struct {
|
||||
Name string `json:"name"`
|
||||
Avatar string `json:"avatar"` // seed آواتارِ کاربر
|
||||
Host bool `json:"host"`
|
||||
Name string `json:"name"`
|
||||
Avatar string `json:"avatar"` // seed آواتارِ کاربر
|
||||
Frame string `json:"frame"` // قابِ آواتارِ انتخابی
|
||||
AvatarImg string `json:"avatar_img"` // شخصیتِ آواتارِ انتخابی (خالی ⇒ تصادفی)
|
||||
Host bool `json:"host"`
|
||||
Seat int `json:"seat"` // جایگاهِ مطلق (۰..۳)
|
||||
}
|
||||
|
||||
@@ -65,7 +67,9 @@ type PlayerInfo struct {
|
||||
Connected bool `json:"connected"`
|
||||
Coins int64 `json:"coins"`
|
||||
RankTier string `json:"rank_tier"` // bronze..king (برای نشانِ رتبه)
|
||||
Avatar string `json:"avatar"` // seed آواتارِ کاربر
|
||||
Avatar string `json:"avatar"` // seed آواتارِ کاربر
|
||||
Frame string `json:"frame"` // قابِ آواتارِ انتخابی (کازمتیک)
|
||||
AvatarImg string `json:"avatar_img"` // شخصیتِ آواتارِ انتخابی (خالی ⇒ تصادفی)
|
||||
}
|
||||
|
||||
// matchedMsg به کلاینت پس از تشکیل میز.
|
||||
|
||||
+15
-33
@@ -40,6 +40,8 @@ type seatInfo struct {
|
||||
coins int64 // برای نمایش سرِ میز
|
||||
rankTier string // نشانِ رتبه برای نمایش سرِ میز
|
||||
avatar string // seed آواتارِ انتخابیِ کاربر (برای نمایش سرِ میز)
|
||||
frame string // قابِ آواتارِ انتخابیِ کاربر (کازمتیک)
|
||||
avatarImg string // شناسهی شخصیتِ آواتارِ انتخابی (خالی ⇒ آواتارِ تصادفی)
|
||||
}
|
||||
|
||||
// auto مشخص میکند جایگاه باید خودکار بازی شود (بات یا قطعشده).
|
||||
@@ -70,6 +72,9 @@ type Room struct {
|
||||
// graceUntil: تا این لحظه هیچ حرکتِ خودکاری انجام نمیشود (هماهنگ با انیمیشنهای کلاینت).
|
||||
graceUntil time.Time
|
||||
chatAt [4]time.Time // آخرین زمانِ چتِ هر جایگاه (ضدِ اسپم)
|
||||
// seen: حافظهی کارتهای بازیشده در هَندِ جاری (برای هوشِ باتِ کارتشمار).
|
||||
// بیتِ (rank-2) از seen[suit] یعنی آن کارت رو شده. هر هَند ریست میشود.
|
||||
seen [4]uint16
|
||||
}
|
||||
|
||||
func newRoom(id string, seats [4]*seatInfo, hub *Hub) *Room {
|
||||
@@ -144,7 +149,7 @@ func (r *Room) start() {
|
||||
if s.isBot || s.userID == 0 {
|
||||
continue
|
||||
}
|
||||
s.coins, s.rankTier, s.avatar = r.settler.PlayerProfile(s.userID)
|
||||
s.coins, s.rankTier, s.avatar, s.frame, s.avatarImg = r.settler.PlayerProfile(s.userID)
|
||||
}
|
||||
for seat, s := range r.seats {
|
||||
if s.client != nil {
|
||||
@@ -280,6 +285,7 @@ func (r *Room) handleInput(act roomAction) {
|
||||
r.sendError(act.seat, err.Error())
|
||||
return
|
||||
}
|
||||
r.seen = [4]uint16{} // شروعِ فازِ بازیِ هَند ⇒ ریستِ حافظهی کارتها
|
||||
r.graceUntil = time.Now().Add(r.dealDelay) // انیمیشنِ پخشِ بقیهی کارتها
|
||||
r.react()
|
||||
case "reshuffle":
|
||||
@@ -333,50 +339,24 @@ func (r *Room) autoMove(seat int) {
|
||||
}
|
||||
}
|
||||
|
||||
// autoChooseTrump خالی را انتخاب میکند که بازیکن بیشترین کارت از آن را دارد.
|
||||
// autoChooseTrump حکم را با راهبردِ باتِ هوشمند (طول + قدرتِ خال) انتخاب میکند.
|
||||
func (r *Room) autoChooseTrump(seat int) {
|
||||
hand := r.game.Hand(seat)
|
||||
if len(hand) == 0 {
|
||||
return
|
||||
}
|
||||
var counts [4]int
|
||||
for _, c := range hand {
|
||||
counts[c.Suit]++
|
||||
}
|
||||
best, bestN := hand[0].Suit, -1
|
||||
for s := game.Suit(0); s < 4; s++ {
|
||||
if counts[s] > bestN {
|
||||
bestN, best = counts[s], s
|
||||
}
|
||||
}
|
||||
_ = r.game.ChooseTrump(seat, best)
|
||||
_ = r.game.ChooseTrump(seat, botChooseTrump(hand))
|
||||
r.seen = [4]uint16{} // شروعِ فازِ بازیِ هَند ⇒ ریستِ حافظهی کارتها
|
||||
r.graceUntil = time.Now().Add(r.dealDelay) // انیمیشنِ پخشِ بقیهی کارتها
|
||||
}
|
||||
|
||||
// autoPlayCard یک کارت مجاز (پایینترین کارتِ خالِ زمینه، وگرنه پایینترین کارت) بازی میکند.
|
||||
// autoPlayCard با هوشِ باتِ اکتشافی یک کارتِ مجاز و راهبردی بازی میکند.
|
||||
func (r *Room) autoPlayCard(seat int) {
|
||||
hand := r.game.Hand(seat)
|
||||
if len(hand) == 0 {
|
||||
return
|
||||
}
|
||||
var choice game.Card
|
||||
found := false
|
||||
if len(r.game.Trick) > 0 { // خالِ زمینه تعیین شده
|
||||
lead := r.game.LeadSuit
|
||||
for _, c := range hand {
|
||||
if c.Suit == lead && (!found || c.Rank < choice.Rank) {
|
||||
choice, found = c, true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
choice = hand[0]
|
||||
for _, c := range hand {
|
||||
if c.Rank < choice.Rank {
|
||||
choice = c
|
||||
}
|
||||
}
|
||||
}
|
||||
choice := botPlay(hand, r.game.Trick, r.game.Trump, r.game.LeadSuit, r.seen)
|
||||
_ = r.playCard(seat, choice)
|
||||
}
|
||||
|
||||
@@ -386,6 +366,7 @@ func (r *Room) playCard(seat int, card game.Card) error {
|
||||
if err := r.game.PlayCard(seat, card); err != nil {
|
||||
return err
|
||||
}
|
||||
r.seen[card.Suit] |= 1 << (card.Rank - 2) // ثبت در حافظهی کارتشمارِ بات
|
||||
// بُرِش: کارتِ غیرِاولِ دست، از خالِ حکم، در حالی که خالِ زمینه حکم نبوده
|
||||
// ⇒ بازیکن خالِ زمینه را نداشته و با حکم بریده است.
|
||||
if !wasFirst && card.Suit == r.game.Trump && r.game.LeadSuit != r.game.Trump {
|
||||
@@ -568,7 +549,8 @@ func (r *Room) playersInfo() []PlayerInfo {
|
||||
out[i] = PlayerInfo{
|
||||
Seat: i, Name: s.name, Bot: s.isBot,
|
||||
Connected: s.isBot || s.connected,
|
||||
Coins: s.coins, RankTier: s.rankTier, Avatar: s.avatar,
|
||||
Coins: s.coins, RankTier: s.rankTier, Avatar: s.avatar, Frame: s.frame,
|
||||
AvatarImg: s.avatarImg,
|
||||
}
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -12,8 +12,9 @@ type Settler interface {
|
||||
RecordHand(userID int64, won, kot, asHakem bool) // پایان یک هَند
|
||||
RecordCut(userID int64) // بُرِش با حکم
|
||||
RecordGameResult(userID int64, won bool) // پایان بازی (برد/باخت)
|
||||
// PlayerProfile سکه، نشانِ رتبه و seedِ آواتارِ بازیکن را برای نمایش سرِ میز برمیگرداند.
|
||||
PlayerProfile(userID int64) (coins int64, rankTier string, avatar string)
|
||||
// PlayerProfile سکه، نشانِ رتبه، آواتار، قاب و شخصیتِ آواتارِ انتخابیِ بازیکن را
|
||||
// برای نمایش سرِ میز برمیگرداند (avatarImg خالی ⇒ آواتارِ تصادفیِ پیشفرض).
|
||||
PlayerProfile(userID int64) (coins int64, rankTier, avatar, frame, avatarImg string)
|
||||
// ChargeTicket یک بلیت برای بُرِ مجدد در فازِ انتخابِ حکم مصرف میکند.
|
||||
ChargeTicket(userID int64) error
|
||||
// ChargeCoins/AwardCoins برای شرطِ سکهایِ میزِ خصوصی (pot). Charge در صورتِ
|
||||
@@ -38,7 +39,9 @@ func (noopSettler) RecordGame(string, string, int, bool) {}
|
||||
func (noopSettler) RecordHand(int64, bool, bool, bool) {}
|
||||
func (noopSettler) RecordCut(int64) {}
|
||||
func (noopSettler) RecordGameResult(int64, bool) {}
|
||||
func (noopSettler) PlayerProfile(int64) (int64, string, string) { return 0, "", "" }
|
||||
func (noopSettler) PlayerProfile(int64) (int64, string, string, string, string) {
|
||||
return 0, "", "", "", ""
|
||||
}
|
||||
func (noopSettler) ChargeTicket(int64) error { return nil }
|
||||
func (noopSettler) ChargeCoins(int64, int64, string) error { return nil }
|
||||
func (noopSettler) AwardCoins(int64, int64, string) {}
|
||||
|
||||
@@ -481,7 +481,9 @@ func (s *recordingSettler) RecordGameResult(int64, bool) {
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *recordingSettler) PlayerProfile(int64) (int64, string, string) { return 0, "", "" }
|
||||
func (s *recordingSettler) PlayerProfile(int64) (int64, string, string, string, string) {
|
||||
return 0, "", "", "", ""
|
||||
}
|
||||
func (s *recordingSettler) ChargeTicket(int64) error { return nil }
|
||||
func (s *recordingSettler) ChargeCoins(int64, int64, string) error { return nil }
|
||||
func (s *recordingSettler) AwardCoins(int64, int64, string) {}
|
||||
|
||||
Reference in New Issue
Block a user