462 lines
14 KiB
Go
462 lines
14 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"`
|
||
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")
|
||
}
|