Files
back-hokm/internal/economy/economy.go
T
2026-06-15 20:54:05 +03:30

412 lines
12 KiB
Go

package economy
import (
"context"
"database/sql"
"errors"
"strings"
"sync"
"time"
)
// خطاهای دامنه.
var (
ErrTooSoon = errors.New("daily reward not ready")
ErrAdNotVerified = errors.New("ad not verified")
ErrAdCap = errors.New("daily ad cap reached")
ErrAdDuplicate = errors.New("ad reward already claimed")
ErrNotFound = errors.New("item not found")
ErrAlreadyOwned = errors.New("already owned")
ErrNotOwned = errors.New("card not owned")
ErrIAPInvalid = errors.New("purchase not valid")
)
const tsLayout = "2006-01-02 15:04:05"
func nowUTC() time.Time { return time.Now().UTC() }
func nowStr() string { return nowUTC().Format(tsLayout) }
func parseTS(s string) time.Time {
t, _ := time.Parse(tsLayout, s)
return t
}
// Service سرویس اقتصاد بازی.
type Service struct {
db *sql.DB
ad AdVerifier
iap IAPVerifier
mu sync.RWMutex // محافظ کشِ کاتالوگ
cat Catalog // کشِ کاتالوگ خوانده‌شده از DB
}
func New(db *sql.DB, ad AdVerifier, iap IAPVerifier) *Service {
return &Service{db: db, ad: ad, iap: iap}
}
// Profile وضعیت اقتصادی کاربر.
type Profile struct {
Coins int64 `json:"coins"`
Tickets int64 `json:"tickets"`
XP int64 `json:"xp"`
Trophies int `json:"trophies"`
Level int `json:"level"`
XPInto int64 `json:"xp_into_level"`
XPNeed int64 `json:"xp_for_next"`
VIP bool `json:"vip"`
VIPUntil *string `json:"vip_until"`
SelectedCard string `json:"selected_card"`
}
// GetProfile وضعیت اقتصادی کاربر را می‌خواند.
func (s *Service) GetProfile(ctx context.Context, userID int64) (*Profile, error) {
var p Profile
var vipUntil sql.NullString
err := s.db.QueryRowContext(ctx,
`SELECT coins, tickets, xp, trophies, vip_until, selected_card FROM users WHERE id = ?`, userID).
Scan(&p.Coins, &p.Tickets, &p.XP, &p.Trophies, &vipUntil, &p.SelectedCard)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
p.Level, p.XPInto, p.XPNeed = LevelInfo(p.XP)
if vipUntil.Valid {
p.VIPUntil = &vipUntil.String
p.VIP = parseTS(vipUntil.String).After(nowUTC())
}
return &p, nil
}
func (s *Service) isVIP(ctx context.Context, userID int64) bool {
var vu sql.NullString
if err := s.db.QueryRowContext(ctx, `SELECT vip_until FROM users WHERE id = ?`, userID).Scan(&vu); err != nil {
return false
}
return vu.Valid && parseTS(vu.String).After(nowUTC())
}
// ClaimDaily سکه روزانه را در صورت گذشت ۲۴ ساعت می‌دهد.
func (s *Service) ClaimDaily(ctx context.Context, userID int64) (int64, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 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
}
if n, _ := res.RowsAffected(); n == 0 {
return 0, ErrTooSoon
}
if err := adjustTx(ctx, tx, userID, CurrencyCoin, DailyReward, "daily_reward", ""); err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return DailyReward, nil
}
// ClaimAdReward پس از تأیید تبلیغ rewarded، سکه می‌دهد (با ضد تکرار و سقف روزانه).
func (s *Service) ClaimAdReward(ctx context.Context, userID int64, token string) (int64, error) {
ok, err := s.ad.Verify(ctx, userID, token)
if err != nil || !ok {
return 0, ErrAdNotVerified
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback()
// سقف روزانه
since := nowUTC().Add(-24 * time.Hour).Format(tsLayout)
var cnt int
if err := tx.QueryRowContext(ctx,
`SELECT COUNT(*) FROM ad_rewards WHERE user_id = ? AND created_at > ?`, userID, since).Scan(&cnt); err != nil {
return 0, err
}
if cnt >= AdDailyCap {
return 0, ErrAdCap
}
// ثبت توکن یکتا (ضد تکرار)
if _, err := tx.ExecContext(ctx,
`INSERT INTO ad_rewards (user_id, token, amount) VALUES (?, ?, ?)`, userID, token, AdReward); err != nil {
if isUnique(err) {
return 0, ErrAdDuplicate
}
return 0, err
}
if err := adjustTx(ctx, tx, userID, CurrencyCoin, AdReward, "ad_reward", token); err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return AdReward, nil
}
// BuyCard اسکین کارت را با سکه باز می‌کند.
func (s *Service) BuyCard(ctx context.Context, userID int64, cardID string) error {
skin := s.findCardSkin(cardID)
if skin == nil {
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_cards WHERE user_id = ? AND card_id = ?`, userID, cardID).Scan(&exists)
if exists == 1 {
return ErrAlreadyOwned
}
if skin.PriceCoins > 0 {
if err := adjustTx(ctx, tx, userID, CurrencyCoin, -skin.PriceCoins, "buy_card", cardID); err != nil {
return err
}
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO user_cards (user_id, card_id) VALUES (?, ?)`, userID, cardID); err != nil {
return err
}
return tx.Commit()
}
// SelectCard کارت انتخابی کاربر را تنظیم می‌کند (باید مالکش باشد یا کارت پیش‌فرض).
func (s *Service) SelectCard(ctx context.Context, userID int64, cardID string) error {
if s.findCardSkin(cardID) == nil {
return ErrNotFound
}
if cardID != "simple" {
var exists int
_ = s.db.QueryRowContext(ctx,
`SELECT 1 FROM user_cards WHERE user_id = ? AND card_id = ?`, userID, cardID).Scan(&exists)
if exists != 1 {
return ErrNotOwned
}
}
_, err := s.db.ExecContext(ctx, `UPDATE users SET selected_card = ? WHERE id = ?`, cardID, userID)
return err
}
// OwnedCards فهرست کارت‌های متعلق به کاربر (شامل کارت پیش‌فرض).
func (s *Service) OwnedCards(ctx context.Context, userID int64) ([]string, error) {
rows, err := s.db.QueryContext(ctx, `SELECT card_id FROM user_cards WHERE user_id = ?`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
owned := []string{"simple"}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
if id != "simple" {
owned = append(owned, id)
}
}
return owned, rows.Err()
}
// addXPTx مقدار XP را با اعمال بوستر فعال اضافه می‌کند.
func (s *Service) addXPTx(ctx context.Context, tx *sql.Tx, userID int64, base int) error {
mult := 1
var boostUntil sql.NullString
var m int
if err := tx.QueryRowContext(ctx,
`SELECT xp_boost_mult, xp_boost_until FROM users WHERE id = ?`, userID).Scan(&m, &boostUntil); err == nil {
if boostUntil.Valid && parseTS(boostUntil.String).After(nowUTC()) && m > 1 {
mult = m
}
}
_, err := tx.ExecContext(ctx, `UPDATE users SET xp = xp + ? WHERE id = ?`, base*mult, userID)
return err
}
// PurchaseRequest درخواست تأیید خرید IAP.
type PurchaseRequest struct {
Store string `json:"store"` // bazaar | myket
Kind string `json:"kind"` // coin | ticket | booster
ProductID string `json:"product_id"` // شناسه بسته در کاتالوگ
Token string `json:"token"` // purchaseToken
}
// VerifyPurchase خرید را تأیید و در صورت معتبر بودن اعتبار/بلیط/VIP/بوستر می‌دهد (idempotent).
func (s *Service) VerifyPurchase(ctx context.Context, userID int64, req PurchaseRequest) error {
ok, err := s.iap.Verify(ctx, req.Store, req.ProductID, req.Token)
if err != nil || !ok {
return ErrIAPInvalid
}
var coins, tickets int64
var vipDays int
var booster *Booster
switch req.Kind {
case "coin":
p := s.findCoinPackage(req.ProductID)
if p == nil {
return ErrNotFound
}
coins, vipDays = p.Coins, p.VIPDays
if s.isVIP(ctx, userID) {
coins += coins / 10 // پاداش ۱۰٪ VIP
}
case "ticket":
p := s.findTicketPackage(req.ProductID)
if p == nil {
return ErrNotFound
}
tickets = p.Tickets
case "booster":
booster = s.findBooster(req.ProductID)
if booster == nil {
return ErrNotFound
}
default:
return ErrNotFound
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// ثبت خرید (یکتا بودن 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 {
if isUnique(err) {
return ErrAlreadyOwned // قبلاً پردازش شده
}
return err
}
if coins > 0 {
if err := adjustTx(ctx, tx, userID, CurrencyCoin, coins, "purchase_coin", req.ProductID); err != nil {
return err
}
}
if tickets > 0 {
if err := adjustTx(ctx, tx, userID, CurrencyTicket, tickets, "purchase_ticket", req.ProductID); err != nil {
return err
}
}
if vipDays > 0 {
if err := extendVIPTx(ctx, tx, userID, vipDays); err != nil {
return err
}
}
if booster != nil {
until := laterOf(nowUTC(), time.Time{}).Add(time.Duration(booster.Hours) * time.Hour).Format(tsLayout)
if _, err := tx.ExecContext(ctx,
`UPDATE users SET xp_boost_mult = ?, xp_boost_until = ? WHERE id = ?`,
booster.Multiplier, until, userID); err != nil {
return err
}
}
return tx.Commit()
}
// extendVIPTx زمان VIP را تمدید می‌کند (از بیشینهٔ اکنون و پایان فعلی).
func extendVIPTx(ctx context.Context, tx *sql.Tx, userID int64, days int) error {
var cur sql.NullString
if err := tx.QueryRowContext(ctx, `SELECT vip_until FROM users WHERE id = ?`, userID).Scan(&cur); err != nil {
return err
}
base := nowUTC()
if cur.Valid {
if t := parseTS(cur.String); t.After(base) {
base = t
}
}
until := base.Add(time.Duration(days) * 24 * time.Hour).Format(tsLayout)
_, err := tx.ExecContext(ctx, `UPDATE users SET vip_until = ? WHERE id = ?`, until, userID)
return err
}
func laterOf(a, b time.Time) time.Time {
if a.After(b) {
return a
}
return b
}
// --- تسویه‌ی داخل بازی ---
// ChargeEntry ورودی میز را از کاربر کسر می‌کند.
func (s *Service) ChargeEntry(ctx context.Context, userID, entry int64, ref string) error {
if entry <= 0 {
return nil
}
return s.Adjust(ctx, userID, CurrencyCoin, -entry, "table_entry", ref)
}
// Refund ورودی میز را بازمی‌گرداند (در صورت لغو بازی).
func (s *Service) Refund(ctx context.Context, userID, entry int64, ref string) error {
if entry <= 0 {
return nil
}
return s.Adjust(ctx, userID, CurrencyCoin, entry, "table_refund", ref)
}
// AwardWinner جایزه، XP و جام را به برنده می‌دهد.
func (s *Service) AwardWinner(ctx context.Context, userID, prize int64, xp, trophy int, ref string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if prize > 0 {
if err := adjustTx(ctx, tx, userID, CurrencyCoin, prize, "table_prize", ref); err != nil {
return err
}
}
if xp > 0 {
if err := s.addXPTx(ctx, tx, userID, xp); err != nil {
return err
}
}
if trophy > 0 {
if _, err := tx.ExecContext(ctx, `UPDATE users SET trophies = trophies + ? WHERE id = ?`, trophy, userID); err != nil {
return err
}
}
return tx.Commit()
}
// RecordGame نتیجه‌ی بازی را در تاریخچه ثبت می‌کند.
func (s *Service) RecordGame(ctx context.Context, room, playersJSON string, winnerTeam int, kot bool) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO game_history (room, players_json, winner_team, kot) VALUES (?, ?, ?, ?)`,
room, playersJSON, winnerTeam, boolToInt(kot))
return err
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
func isUnique(err error) bool {
return err != nil && strings.Contains(strings.ToLower(err.Error()), "unique")
}