Files
back-hokm/internal/economy/economy.go
T
2026-07-04 21:21:13 +03:30

462 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"`
SelectedCarpet string `json:"selected_carpet"`
RankPoints int64 `json:"rank_points"`
RankTier string `json:"rank_tier"` // bronze..king
RankIndex int `json:"rank_index"` // ۰..۴
}
// 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, selected_carpet, rank_points FROM users WHERE id = ?`, userID).
Scan(&p.Coins, &p.Tickets, &p.XP, &p.Trophies, &vipUntil, &p.SelectedCard, &p.SelectedCarpet, &p.RankPoints)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
p.Level, p.XPInto, p.XPNeed = LevelInfo(p.XP)
p.RankTier, p.RankIndex = RankTier(p.RankPoints)
if vipUntil.Valid {
p.VIPUntil = &vipUntil.String
p.VIP = parseTS(vipUntil.String).After(nowUTC())
}
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 | vip
ProductID string `json:"product_id"` // شناسه بسته در کاتالوگ (SKU فروشگاه)
Token string `json:"token"` // purchaseToken
// برای تأیید امضای آفلاین (کافه‌بازار): متنِ خامِ خرید و امضای RSA (base64).
PurchaseData string `json:"purchase_data"`
Signature string `json:"signature"`
}
// VerifyPurchase خرید را تأیید و در صورت معتبر بودن اعتبار/بلیط/VIP/بوستر می‌دهد (idempotent).
func (s *Service) VerifyPurchase(ctx context.Context, userID int64, req PurchaseRequest) error {
// ابتدا بسته را از کاتالوگ می‌یابیم تا پاداش و SKU (شناسه‌ی محصول در فروشگاه)
// را به‌دست آوریم؛ سپس با همان SKU خرید را تأیید می‌کنیم.
var coins, tickets int64
var vipDays int
var booster *Booster
var sku string
switch req.Kind {
case "coin":
p := s.findCoinPackage(req.ProductID)
if p == nil {
return ErrNotFound
}
coins, vipDays, sku = p.Coins, p.VIPDays, p.SKU
if s.isVIP(ctx, userID) {
coins += coins / 10 // پاداش ۱۰٪ VIP
}
case "ticket":
p := s.findTicketPackage(req.ProductID)
if p == nil {
return ErrNotFound
}
tickets, sku = p.Tickets, p.SKU
case "booster":
booster = s.findBooster(req.ProductID)
if booster == nil {
return ErrNotFound
}
sku = booster.SKU
case "vip":
p := s.findVIPPackage(req.ProductID)
if p == nil {
return ErrNotFound
}
vipDays, sku = p.Months*30, p.SKU // هر ماه = ۳۰ روز
default:
return ErrNotFound
}
if sku == "" {
sku = req.ProductID // اگر SKU تنظیم نشده، از شناسه‌ی کاتالوگ استفاده می‌شود
}
ok, err := s.iap.Verify(ctx, IAPProof{
Store: req.Store,
ProductID: sku,
Token: req.Token,
PurchaseData: req.PurchaseData,
Signature: req.Signature,
})
if err != nil || !ok {
return ErrIAPInvalid
}
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
}
// 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
}
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")
}