feat: add private and profile

This commit is contained in:
2026-06-17 11:32:51 +03:30
parent 02060a4dc0
commit 0827858752
23 changed files with 972 additions and 25 deletions
+9
View File
@@ -41,6 +41,14 @@ type Booster struct {
PriceToman int `json:"price_toman"`
}
// VIPPackage بسته‌ی اشتراک VIP با پول واقعی (IAP).
type VIPPackage struct {
ID string `json:"id"`
Title string `json:"title"`
Months int `json:"months"`
PriceToman int `json:"price_toman"`
}
// TableTier نوع میز: تعداد دست، ورودی، جایزه، XP و جام.
type TableTier struct {
ID string `json:"id"`
@@ -58,6 +66,7 @@ type Catalog struct {
TicketPackages []TicketPackage `json:"ticket_packages"`
CardSkins []CardSkin `json:"card_skins"`
Boosters []Booster `json:"boosters"`
VIPPackages []VIPPackage `json:"vip_packages"`
TableTiers []TableTier `json:"table_tiers"`
}
+27
View File
@@ -67,6 +67,21 @@ func (s *Service) LoadCatalog(ctx context.Context) error {
}
rows.Close()
rows, err = s.db.QueryContext(ctx,
`SELECT id, title, months, price_toman FROM vip_packages WHERE enabled = 1 ORDER BY sort`)
if err != nil {
return err
}
for rows.Next() {
var p VIPPackage
if err := rows.Scan(&p.ID, &p.Title, &p.Months, &p.PriceToman); err != nil {
rows.Close()
return err
}
c.VIPPackages = append(c.VIPPackages, p)
}
rows.Close()
rows, err = s.db.QueryContext(ctx,
`SELECT id, title, hands, entry, prize, xp, trophy FROM table_tiers WHERE enabled = 1 ORDER BY sort`)
if err != nil {
@@ -143,6 +158,18 @@ func (s *Service) findBooster(id string) *Booster {
return nil
}
func (s *Service) findVIPPackage(id string) *VIPPackage {
s.mu.RLock()
defer s.mu.RUnlock()
for i := range s.cat.VIPPackages {
if s.cat.VIPPackages[i].ID == id {
p := s.cat.VIPPackages[i]
return &p
}
}
return nil
}
// FindTier نوع میز را از کش برمی‌گرداند (برای لایه بازی).
func (s *Service) FindTier(id string) *TableTier {
s.mu.RLock()
+30 -1
View File
@@ -242,7 +242,7 @@ func (s *Service) addXPTx(ctx context.Context, tx *sql.Tx, userID int64, base in
// PurchaseRequest درخواست تأیید خرید IAP.
type PurchaseRequest struct {
Store string `json:"store"` // bazaar | myket
Kind string `json:"kind"` // coin | ticket | booster
Kind string `json:"kind"` // coin | ticket | booster | vip
ProductID string `json:"product_id"` // شناسه بسته در کاتالوگ
Token string `json:"token"` // purchaseToken
}
@@ -278,6 +278,12 @@ func (s *Service) VerifyPurchase(ctx context.Context, userID int64, req Purchase
if booster == nil {
return ErrNotFound
}
case "vip":
p := s.findVIPPackage(req.ProductID)
if p == nil {
return ErrNotFound
}
vipDays = p.Months * 30 // هر ماه = ۳۰ روز
default:
return ErrNotFound
}
@@ -341,6 +347,29 @@ func extendVIPTx(ctx context.Context, tx *sql.Tx, userID int64, days int) error
return err
}
// GrantVIPDays به‌صورت دستی (ادمین) اشتراک VIP را تمدید می‌کند.
func (s *Service) GrantVIPDays(ctx context.Context, userID int64, days int) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if err := extendVIPTx(ctx, tx, userID, days); err != nil {
return err
}
return tx.Commit()
}
// GrantCard به‌صورت دستی (ادمین) یک اسکین کارت را برای کاربر باز می‌کند.
func (s *Service) GrantCard(ctx context.Context, userID int64, cardID string) error {
if s.findCardSkin(cardID) == nil {
return ErrNotFound
}
_, err := s.db.ExecContext(ctx,
`INSERT OR IGNORE INTO user_cards (user_id, card_id) VALUES (?, ?)`, userID, cardID)
return err
}
func laterOf(a, b time.Time) time.Time {
if a.After(b) {
return a
+38
View File
@@ -138,6 +138,44 @@ func (h *Handler) Purchase(w http.ResponseWriter, r *http.Request) {
}
}
// Stats — GET /api/stats (آمار پروفایل؛ نمایشِ کامل ویژه‌ی کاربران VIP)
func (h *Handler) Stats(w http.ResponseWriter, r *http.Request) {
id, ok := uid(r)
if !ok {
httpx.Error(w, http.StatusUnauthorized, "unauthorized")
return
}
p, err := h.svc.GetProfile(r.Context(), id)
if err != nil {
httpx.Error(w, http.StatusNotFound, "user not found")
return
}
resp := map[string]any{"vip": p.VIP}
if p.VIP {
st, err := h.svc.GetStats(r.Context(), id)
if err != nil {
httpx.Error(w, http.StatusInternalServerError, "server error")
return
}
resp["stats"] = st
}
httpx.JSON(w, http.StatusOK, resp)
}
// TablesInfo — GET /api/tables/info (باقیمانده‌ی میزهای خصوصیِ رایگان)
func (h *Handler) TablesInfo(w http.ResponseWriter, r *http.Request) {
id, ok := uid(r)
if !ok {
httpx.Error(w, http.StatusUnauthorized, "unauthorized")
return
}
remaining, unlimited := h.svc.PrivateTableInfo(r.Context(), id)
httpx.JSON(w, http.StatusOK, map[string]any{
"remaining": remaining,
"unlimited": unlimited,
})
}
// Daily — POST /api/rewards/daily
func (h *Handler) Daily(w http.ResponseWriter, r *http.Request) {
id, ok := uid(r)
+86
View File
@@ -0,0 +1,86 @@
package economy
import (
"context"
"database/sql"
)
// Stats آمار بازیِ کاربر برای نمایش در پروفایل.
type Stats struct {
Games int64 `json:"games"` // بازی کل
Wins int64 `json:"wins"` // برد کل
Losses int64 `json:"losses"` // باخت کل
KotMade int64 `json:"kot_made"` // کُت کردن
KotReceived int64 `json:"kot_received"` // کُت شدن
Cuts int64 `json:"cuts"` // بریدن (با حکم)
HakemCount int64 `json:"hakem_count"` // دست حاکم
}
// GetStats آمار کاربر را می‌خواند (اگر ردیفی نباشد، صفر برمی‌گرداند).
func (s *Service) GetStats(ctx context.Context, userID int64) (*Stats, error) {
var st Stats
err := s.db.QueryRowContext(ctx,
`SELECT games, wins, losses, kot_made, kot_received, cuts, hakem_count
FROM user_stats WHERE user_id = ?`, userID).
Scan(&st.Games, &st.Wins, &st.Losses, &st.KotMade, &st.KotReceived, &st.Cuts, &st.HakemCount)
if err == sql.ErrNoRows {
return &st, nil // هنوز بازی‌ای ثبت نشده
}
if err != nil {
return nil, err
}
return &st, nil
}
// bumpStats یک یا چند شمارنده را به‌صورت upsert افزایش می‌دهد.
// fields نگاشتِ ستون→مقدار افزوده است؛ ستون‌ها از مجموعه‌ی ثابتِ زیر می‌آیند (نه ورودی کاربر).
func (s *Service) bumpStats(ctx context.Context, userID int64, games, wins, losses, kotMade, kotRecv, cuts, hakem int64) {
_, err := s.db.ExecContext(ctx,
`INSERT INTO user_stats (user_id, games, wins, losses, kot_made, kot_received, cuts, hakem_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
games = games + excluded.games,
wins = wins + excluded.wins,
losses = losses + excluded.losses,
kot_made = kot_made + excluded.kot_made,
kot_received = kot_received + excluded.kot_received,
cuts = cuts + excluded.cuts,
hakem_count = hakem_count + excluded.hakem_count`,
userID, games, wins, losses, kotMade, kotRecv, cuts, hakem)
if err != nil {
// آمار غیرحیاتی است؛ خطا را لاگ‌نشده می‌گذریم تا جریان بازی مختل نشود.
_ = err
}
}
// RecordHand یک هَندِ پایان‌یافته را برای کاربر ثبت می‌کند.
func (s *Service) RecordHand(ctx context.Context, userID int64, won, kot, asHakem bool) {
var kotMade, kotRecv, hakem int64
if asHakem {
hakem = 1
}
if kot {
if won {
kotMade = 1
} else {
kotRecv = 1
}
}
s.bumpStats(ctx, userID, 0, 0, 0, kotMade, kotRecv, 0, hakem)
}
// RecordCut یک بُرِش با حکم را برای کاربر ثبت می‌کند.
func (s *Service) RecordCut(ctx context.Context, userID int64) {
s.bumpStats(ctx, userID, 0, 0, 0, 0, 0, 1, 0)
}
// RecordGameResult پایانِ یک بازی را برای کاربر ثبت می‌کند (برد/باخت).
func (s *Service) RecordGameResult(ctx context.Context, userID int64, won bool) {
var wins, losses int64
if won {
wins = 1
} else {
losses = 1
}
s.bumpStats(ctx, userID, 1, wins, losses, 0, 0, 0, 0)
}
+44
View File
@@ -0,0 +1,44 @@
package economy
import (
"context"
"errors"
)
// FreePrivateTables سقفِ میزهای خصوصیِ رایگان برای کاربرِ غیر VIP.
const FreePrivateTables = 5
// ErrTableLimit وقتی کاربرِ غیر VIP به سقفِ میزهای رایگان رسیده باشد.
var ErrTableLimit = errors.New("free private table limit reached")
// PrivateTableInfo باقیمانده‌ی میزهای رایگان و نامحدود بودن (VIP) را برمی‌گرداند.
func (s *Service) PrivateTableInfo(ctx context.Context, userID int64) (remaining int, unlimited bool) {
if s.isVIP(ctx, userID) {
return 0, true
}
var used int
_ = s.db.QueryRowContext(ctx, `SELECT free_tables_used FROM users WHERE id = ?`, userID).Scan(&used)
rem := FreePrivateTables - used
if rem < 0 {
rem = 0
}
return rem, false
}
// ConsumePrivateTable در صورت مجاز بودن، یک میز خصوصی را برای کاربر مصرف می‌کند.
// برای VIP بدون محدودیت است؛ برای غیر VIP در صورت اتمامِ سقف خطا می‌دهد.
func (s *Service) ConsumePrivateTable(ctx context.Context, userID int64) error {
if s.isVIP(ctx, userID) {
return nil
}
res, err := s.db.ExecContext(ctx,
`UPDATE users SET free_tables_used = free_tables_used + 1
WHERE id = ? AND free_tables_used < ?`, userID, FreePrivateTables)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrTableLimit
}
return nil
}