Files
back-hokm/internal/economy/economy.go
T
2026-07-10 00:56:03 +03:30

547 lines
18 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"
"net/url"
"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
publicURL string // مبدأِ عمومیِ سرور برای ساختِ URL کاملِ تصاویر
mu sync.RWMutex // محافظ کشِ کاتالوگ
cat Catalog // کشِ کاتالوگ خوانده‌شده از DB
}
func New(db *sql.DB, ad AdVerifier, iap IAPVerifier) *Service {
return &Service{db: db, ad: ad, iap: iap}
}
// SetPublicURL مبدأِ عمومیِ سرور را تنظیم می‌کند (برای URLهای کاملِ تصاویر).
func (s *Service) SetPublicURL(u string) { s.publicURL = strings.TrimRight(u, "/") }
// imgURL یک URLِ کاملِ URL-safe برای فایلی در پوشه‌ی [dir] می‌سازد ("" اگر نام خالی
// باشد). نامِ فایل encode می‌شود تا فاصله/کاراکترِ خاص مشکلی ایجاد نکند.
func (s *Service) imgURL(dir, name string) string {
if name == "" {
return ""
}
return s.publicURL + "/" + dir + "/" + url.PathEscape(name)
}
// 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"`
SelectedFrame string `json:"selected_frame"`
SelectedAvatar string `json:"selected_avatar"` // شناسه‌ی شخصیتِ انتخابی (برای وضعیتِ انتخاب در فروشگاه)
SelectedAvatarImg string `json:"selected_avatar_img"` // URL کاملِ تصویرِ شخصیت — خالی ⇒ آواتارِ تصادفی
SelectedCarpetImg string `json:"selected_carpet_img"` // URL کاملِ تصویرِ فرش — خالی ⇒ میزِ کلاسیک
RankPoints int64 `json:"rank_points"`
RankTier string `json:"rank_tier"` // bronze..king
RankIndex int `json:"rank_index"` // ۰..۴
Avatar string `json:"avatar"` // seed آواتار (برای نمایش سرِ میز)
DailyStreak int `json:"daily_streak"`
BestStreak int `json:"best_streak"`
DailyReady bool `json:"daily_ready"` // آیا امروز سکه‌ی روزانه قابلِ دریافت است
NextDaily int64 `json:"next_daily"` // جایزه‌ی روزانه‌ی بعدی (پیش‌نمایش)
}
// GetProfile وضعیت اقتصادی کاربر را می‌خواند.
func (s *Service) GetProfile(ctx context.Context, userID int64) (*Profile, error) {
var p Profile
var vipUntil, avatar, lastDaily, avatarImg sql.NullString
err := s.db.QueryRowContext(ctx,
`SELECT coins, tickets, xp, trophies, vip_until, selected_card, selected_carpet, selected_frame, selected_avatar,
(SELECT id || '.' || img_ext FROM avatars WHERE id = users.selected_avatar AND enabled = 1),
rank_points, avatar, daily_streak, best_streak, last_daily_at FROM users WHERE id = ?`, userID).
Scan(&p.Coins, &p.Tickets, &p.XP, &p.Trophies, &vipUntil, &p.SelectedCard, &p.SelectedCarpet, &p.SelectedFrame, &p.SelectedAvatar, &avatarImg, &p.RankPoints, &avatar, &p.DailyStreak, &p.BestStreak, &lastDaily)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
p.Avatar = avatar.String
p.SelectedAvatarImg = s.imgURL("avatars", avatarImg.String)
// فرش: 'classic' تصویری ندارد (میزِ ترسیمی)؛ بقیه از /carpets/<id>.jpg.
if p.SelectedCarpet != "" && p.SelectedCarpet != "classic" {
p.SelectedCarpetImg = s.imgURL("carpets", p.SelectedCarpet+".jpg")
}
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())
}
// وضعیتِ سکه‌ی روزانه: اگر امروز (تهران) نگرفته، قابلِ دریافت است. اگر یک روز
// جا افتاده باشد، استریکِ بعدی از ۱ شروع می‌شود.
today := tehranDate(nowUTC())
nextStreak := p.DailyStreak + 1
if lastDaily.Valid {
lastDate := tehranDate(parseTS(lastDaily.String))
p.DailyReady = lastDate != today
if lastDate != tehranDate(nowUTC().Add(-24*time.Hour)) && lastDate != today {
nextStreak = 1 // روزی جا افتاده ⇒ ریست
}
} else {
p.DailyReady = true
nextStreak = 1
}
p.NextDaily = streakReward(nextStreak)
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())
}
// tehranDate تاریخِ تقویمیِ تهران (YYYY-MM-DD) را برمی‌گرداند. مرزِ «روز» برای
// استریک، نیمه‌شبِ تهران است تا با حسِ کاربر بخواند.
func tehranDate(t time.Time) string { return t.In(iranZone).Format("2006-01-02") }
// streakReward جایزه‌ی روزِ n اُمِ استریک: پایه + پله (سقفِ روز ۷) + پاداشِ هفتگی.
func streakReward(streak int) int64 {
n := streak
if n > 7 {
n = 7
}
r := int64(DailyReward) + int64(n-1)*50 // روز۱=۲۰۰ … روز۷=۵۰۰
if streak%7 == 0 { // هر ۷ روز پیاپی، پاداشِ ویژه
r += 500
}
return r
}
// ClaimDaily سکه‌ی روزانه را (یک‌بار در هر روزِ تقویمیِ تهران) می‌دهد و استریک را
// به‌روزرسانی می‌کند. برمی‌گرداند: جایزه، استریکِ جدید، بهترین استریک.
func (s *Service) ClaimDaily(ctx context.Context, userID int64) (reward int64, streak int, best int, err error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, 0, 0, err
}
defer tx.Rollback()
var last sql.NullString
if err = tx.QueryRowContext(ctx,
`SELECT last_daily_at, daily_streak, best_streak FROM users WHERE id = ?`, userID).
Scan(&last, &streak, &best); err != nil {
return 0, 0, 0, err
}
today := tehranDate(nowUTC())
if last.Valid {
lastDate := tehranDate(parseTS(last.String))
if lastDate == today {
return 0, 0, 0, ErrTooSoon // امروز قبلاً گرفته
}
if lastDate == tehranDate(nowUTC().Add(-24*time.Hour)) {
streak++ // دیروز گرفته ⇒ ادامه‌ی استریک
} else {
streak = 1 // یک روز جا افتاده ⇒ ریست
}
} else {
streak = 1
}
if streak > best {
best = streak
}
reward = streakReward(streak)
if _, err = tx.ExecContext(ctx,
`UPDATE users SET last_daily_at = ?, daily_streak = ?, best_streak = ? WHERE id = ?`,
nowStr(), streak, best, userID); err != nil {
return 0, 0, 0, err
}
if err = adjustTx(ctx, tx, userID, CurrencyCoin, reward, "daily_reward", ""); err != nil {
return 0, 0, 0, err
}
if err = tx.Commit(); err != nil {
return 0, 0, 0, err
}
return reward, streak, best, 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")
}