feat: add private and profile
This commit is contained in:
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user