This commit is contained in:
2026-06-15 16:53:25 +03:30
commit 390ef96e7e
34 changed files with 4463 additions and 0 deletions
+175
View File
@@ -0,0 +1,175 @@
package economy
// مقادیر اقتصادی پایه (قابل تنظیم بر اساس بیزینس‌پلن).
const (
DailyReward = 200 // سکه روزانه
AdReward = 50 // سکه به ازای هر تبلیغ rewardedِ کامل
AdDailyCap = 50 // سقف تعداد تبلیغ پاداش‌دار در روز
)
// CoinPackage بسته‌ی سکه با پول واقعی (IAP) — ممکن است VIP هم بدهد.
type CoinPackage struct {
ID string `json:"id"`
Title string `json:"title"`
Coins int64 `json:"coins"`
VIPDays int `json:"vip_days"`
PriceToman int `json:"price_toman"`
BonusPct int `json:"bonus_pct"`
}
// TicketPackage بسته‌ی بلیط با پول واقعی (IAP).
type TicketPackage struct {
ID string `json:"id"`
Title string `json:"title"`
Tickets int64 `json:"tickets"`
PriceToman int `json:"price_toman"`
}
// CardSkin اسکین کارت که با سکه باز می‌شود (قیمت ۰ یعنی پیش‌فرض رایگان).
type CardSkin struct {
ID string `json:"id"`
Title string `json:"title"`
PriceCoins int64 `json:"price_coins"`
}
// Booster بوستر XP با پول واقعی (IAP).
type Booster struct {
ID string `json:"id"`
Title string `json:"title"`
Multiplier int `json:"multiplier"`
Hours int `json:"hours"`
PriceToman int `json:"price_toman"`
}
// TableTier نوع میز: تعداد دست، ورودی، جایزه، XP و جام.
type TableTier struct {
ID string `json:"id"`
Title string `json:"title"`
Hands int `json:"hands"`
Entry int64 `json:"entry"`
Prize int64 `json:"prize"`
XP int `json:"xp"`
Trophy int `json:"trophy"`
}
// Catalog کل کاتالوگ فروشگاه (برای GET /api/shop).
type Catalog struct {
CoinPackages []CoinPackage `json:"coin_packages"`
TicketPackages []TicketPackage `json:"ticket_packages"`
CardSkins []CardSkin `json:"card_skins"`
Boosters []Booster `json:"boosters"`
TableTiers []TableTier `json:"table_tiers"`
}
// coinPackages — مطابق فروشگاه مرجع.
var coinPackages = []CoinPackage{
{ID: "small", Title: "بسته کوچک", Coins: 6000, VIPDays: 0, PriceToman: 14950, BonusPct: 0},
{ID: "medium", Title: "بسته متوسط", Coins: 20000, VIPDays: 3, PriceToman: 37900, BonusPct: 20},
{ID: "large", Title: "بسته بزرگ", Coins: 80000, VIPDays: 7, PriceToman: 134900, BonusPct: 30},
{ID: "xlarge", Title: "بسته خیلی بزرگ", Coins: 180000, VIPDays: 15, PriceToman: 279900, BonusPct: 40},
{ID: "luxury", Title: "بسته لاکچری", Coins: 280000, VIPDays: 20, PriceToman: 379900, BonusPct: 50},
{ID: "sultans", Title: "بسته سلاطین", Coins: 700000, VIPDays: 30, PriceToman: 999000, BonusPct: 60},
}
var ticketPackages = []TicketPackage{
{ID: "small", Title: "بسته کوچک", Tickets: 10, PriceToman: 6900},
{ID: "medium", Title: "بسته متوسط", Tickets: 50, PriceToman: 24900},
{ID: "large", Title: "بسته بزرگ", Tickets: 150, PriceToman: 64900},
{ID: "xlarge", Title: "بسته خیلی بزرگ", Tickets: 500, PriceToman: 199900},
}
var cardSkins = []CardSkin{
{ID: "simple", Title: "بسته کارت ساده", PriceCoins: 0},
{ID: "swiss", Title: "بسته کارت سوئیسی", PriceCoins: 6000},
{ID: "wizard", Title: "بسته کارت جادوگر", PriceCoins: 10000},
{ID: "western", Title: "بسته کارت وسترن", PriceCoins: 12000},
{ID: "french", Title: "بسته کارت فرانسوی", PriceCoins: 20000},
{ID: "epic", Title: "بسته کارت حماسی", PriceCoins: 25000},
}
var boosters = []Booster{
{ID: "xp2", Title: "بسته تجربه دو برابر", Multiplier: 2, Hours: 12, PriceToman: 6900},
{ID: "xp3", Title: "بسته تجربه سه برابر", Multiplier: 3, Hours: 24, PriceToman: 19900},
}
// tableTiers — انواع میز (ورودی/جایزه/XP/جام).
var tableTiers = []TableTier{
{ID: "beginner", Title: "مبتدی", Hands: 3, Entry: 100, Prize: 250, XP: 15, Trophy: 50},
{ID: "amateur", Title: "پیوست", Hands: 3, Entry: 2500, Prize: 5000, XP: 25, Trophy: 100},
{ID: "pro", Title: "حرفه‌ای", Hands: 7, Entry: 2500, Prize: 5000, XP: 25, Trophy: 100},
{ID: "master", Title: "استادان", Hands: 7, Entry: 20000, Prize: 40000, XP: 40, Trophy: 200},
}
// FullCatalog کل کاتالوگ را برمی‌گرداند.
func FullCatalog() Catalog {
return Catalog{
CoinPackages: coinPackages,
TicketPackages: ticketPackages,
CardSkins: cardSkins,
Boosters: boosters,
TableTiers: tableTiers,
}
}
func findCoinPackage(id string) *CoinPackage {
for i := range coinPackages {
if coinPackages[i].ID == id {
return &coinPackages[i]
}
}
return nil
}
func findTicketPackage(id string) *TicketPackage {
for i := range ticketPackages {
if ticketPackages[i].ID == id {
return &ticketPackages[i]
}
}
return nil
}
func findCardSkin(id string) *CardSkin {
for i := range cardSkins {
if cardSkins[i].ID == id {
return &cardSkins[i]
}
}
return nil
}
func findBooster(id string) *Booster {
for i := range boosters {
if boosters[i].ID == id {
return &boosters[i]
}
}
return nil
}
// FindTier نوع میز را برمی‌گرداند (برای لایه بازی).
func FindTier(id string) *TableTier {
for i := range tableTiers {
if tableTiers[i].ID == id {
return &tableTiers[i]
}
}
return nil
}
// LevelInfo سطح فعلی، XP درون‌سطح و XP لازم برای سطح بعد را از XP کل محاسبه می‌کند.
// نیاز هر سطح: ۵۰ × شماره سطح (مثلاً سطح ۸ ⇒ ۴۰۰).
func LevelInfo(totalXP int64) (level int, into int64, need int64) {
level = 1
remaining := totalXP
for {
need = int64(50 * level)
if remaining < need {
break
}
remaining -= need
level++
}
into = remaining
return level, into, need
}
+407
View File
@@ -0,0 +1,407 @@
package economy
import (
"context"
"database/sql"
"errors"
"strings"
"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
}
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 := 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 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 := 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 := findTicketPackage(req.ProductID)
if p == nil {
return ErrNotFound
}
tickets = p.Tickets
case "booster":
booster = 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")
}
+198
View File
@@ -0,0 +1,198 @@
package economy
import (
"context"
"path/filepath"
"testing"
"hakemsho/internal/store"
)
func setup(t *testing.T) (*Service, int64) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test.db")
st, err := store.Open(dbPath)
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { st.Close() })
res, err := st.DB.Exec(`INSERT INTO users (mobile) VALUES ('09120000000')`)
if err != nil {
t.Fatalf("insert user: %v", err)
}
id, _ := res.LastInsertId()
svc := New(st.DB, DevAdVerifier{}, DevIAPVerifier{})
return svc, id
}
func setCoins(t *testing.T, s *Service, userID, coins int64) {
t.Helper()
if _, err := s.db.Exec(`UPDATE users SET coins = ? WHERE id = ?`, coins, userID); err != nil {
t.Fatal(err)
}
}
func coinsOf(t *testing.T, s *Service, userID int64) int64 {
t.Helper()
p, err := s.GetProfile(context.Background(), userID)
if err != nil {
t.Fatal(err)
}
return p.Coins
}
func TestLevelInfo(t *testing.T) {
cases := []struct {
xp int64
level int
into int64
need int64
}{
{0, 1, 0, 50},
{49, 1, 49, 50},
{50, 2, 0, 100},
{150, 3, 0, 150},
}
for _, c := range cases {
lvl, into, need := LevelInfo(c.xp)
if lvl != c.level || into != c.into || need != c.need {
t.Errorf("LevelInfo(%d) = (%d,%d,%d), want (%d,%d,%d)",
c.xp, lvl, into, need, c.level, c.into, c.need)
}
}
}
func TestWalletInsufficient(t *testing.T) {
svc, id := setup(t)
ctx := context.Background()
setCoins(t, svc, id, 100)
if err := svc.Adjust(ctx, id, CurrencyCoin, -200, "test", ""); err != ErrInsufficient {
t.Fatalf("want ErrInsufficient, got %v", err)
}
if c := coinsOf(t, svc, id); c != 100 {
t.Fatalf("balance changed on failed debit: %d", c)
}
if err := svc.Adjust(ctx, id, CurrencyCoin, -60, "test", ""); err != nil {
t.Fatal(err)
}
if c := coinsOf(t, svc, id); c != 40 {
t.Fatalf("want 40, got %d", c)
}
}
func TestDailyCooldown(t *testing.T) {
svc, id := setup(t)
ctx := context.Background()
if amt, err := svc.ClaimDaily(ctx, id); err != nil || amt != DailyReward {
t.Fatalf("first claim: amt=%d err=%v", amt, err)
}
if _, err := svc.ClaimDaily(ctx, id); err != ErrTooSoon {
t.Fatalf("want ErrTooSoon, got %v", err)
}
if c := coinsOf(t, svc, id); c != DailyReward {
t.Fatalf("want %d coins, got %d", DailyReward, c)
}
}
func TestAdRewardDedupeAndCredit(t *testing.T) {
svc, id := setup(t)
ctx := context.Background()
if amt, err := svc.ClaimAdReward(ctx, id, "tok-1"); err != nil || amt != AdReward {
t.Fatalf("claim: amt=%d err=%v", amt, err)
}
// همان توکن دوباره ⇒ تکراری
if _, err := svc.ClaimAdReward(ctx, id, "tok-1"); err != ErrAdDuplicate {
t.Fatalf("want ErrAdDuplicate, got %v", err)
}
// توکن خالی ⇒ تأیید نشده
if _, err := svc.ClaimAdReward(ctx, id, ""); err != ErrAdNotVerified {
t.Fatalf("want ErrAdNotVerified, got %v", err)
}
if c := coinsOf(t, svc, id); c != AdReward {
t.Fatalf("want %d, got %d", AdReward, c)
}
}
func TestBuyAndSelectCard(t *testing.T) {
svc, id := setup(t)
ctx := context.Background()
setCoins(t, svc, id, 10000)
// انتخاب کارتی که مالکش نیست ⇒ خطا
if err := svc.SelectCard(ctx, id, "wizard"); err != ErrNotOwned {
t.Fatalf("want ErrNotOwned, got %v", err)
}
// خرید کارت جادوگر (۱۰۰۰۰)
if err := svc.BuyCard(ctx, id, "wizard"); err != nil {
t.Fatalf("buy: %v", err)
}
if c := coinsOf(t, svc, id); c != 0 {
t.Fatalf("want 0 after buy, got %d", c)
}
// خرید دوباره ⇒ قبلاً مالک است
if err := svc.BuyCard(ctx, id, "wizard"); err != ErrAlreadyOwned {
t.Fatalf("want ErrAlreadyOwned, got %v", err)
}
// حالا قابل انتخاب است
if err := svc.SelectCard(ctx, id, "wizard"); err != nil {
t.Fatalf("select: %v", err)
}
p, _ := svc.GetProfile(ctx, id)
if p.SelectedCard != "wizard" {
t.Fatalf("selected = %s", p.SelectedCard)
}
// خرید بدون سکه کافی ⇒ insufficient
if err := svc.BuyCard(ctx, id, "epic"); err != ErrInsufficient {
t.Fatalf("want ErrInsufficient, got %v", err)
}
}
func TestPurchaseCreditsAndIdempotent(t *testing.T) {
svc, id := setup(t)
ctx := context.Background()
req := PurchaseRequest{Store: "bazaar", Kind: "coin", ProductID: "small", Token: "pt-1"}
if err := svc.VerifyPurchase(ctx, id, req); err != nil {
t.Fatalf("purchase: %v", err)
}
pkg := findCoinPackage("small")
if c := coinsOf(t, svc, id); c != pkg.Coins {
t.Fatalf("want %d coins, got %d", pkg.Coins, c)
}
// همان توکن دوباره ⇒ قبلاً پردازش شده، بدون شارژ مجدد
if err := svc.VerifyPurchase(ctx, id, req); err != ErrAlreadyOwned {
t.Fatalf("want ErrAlreadyOwned, got %v", err)
}
if c := coinsOf(t, svc, id); c != pkg.Coins {
t.Fatalf("double credit! got %d", c)
}
}
func TestGameSettlement(t *testing.T) {
svc, id := setup(t)
ctx := context.Background()
setCoins(t, svc, id, 1000)
// کسر ورودی
if err := svc.ChargeEntry(ctx, id, 100, "r1"); err != nil {
t.Fatalf("charge: %v", err)
}
if c := coinsOf(t, svc, id); c != 900 {
t.Fatalf("after entry want 900, got %d", c)
}
// جایزه برنده
if err := svc.AwardWinner(ctx, id, 250, 15, 50, "r1"); err != nil {
t.Fatalf("award: %v", err)
}
p, _ := svc.GetProfile(ctx, id)
if p.Coins != 1150 {
t.Fatalf("after prize want 1150, got %d", p.Coins)
}
if p.XP != 15 {
t.Fatalf("want 15 xp, got %d", p.XP)
}
if p.Trophies != 50 {
t.Fatalf("want 50 trophies, got %d", p.Trophies)
}
}
+186
View File
@@ -0,0 +1,186 @@
package economy
import (
"encoding/json"
"errors"
"net/http"
"hakemsho/internal/auth"
"hakemsho/internal/httpx"
)
// Handler endpointهای فروشگاه و کیف‌پول (همه پشت JWT).
type Handler struct {
svc *Service
}
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
func uid(r *http.Request) (int64, bool) { return auth.UserID(r.Context()) }
// Wallet — GET /api/wallet
func (h *Handler) Wallet(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
}
httpx.JSON(w, http.StatusOK, p)
}
// Shop — GET /api/shop
func (h *Handler) Shop(w http.ResponseWriter, r *http.Request) {
id, ok := uid(r)
if !ok {
httpx.Error(w, http.StatusUnauthorized, "unauthorized")
return
}
owned, err := h.svc.OwnedCards(r.Context(), id)
if err != nil {
httpx.Error(w, http.StatusInternalServerError, "server error")
return
}
p, _ := h.svc.GetProfile(r.Context(), id)
selected := "simple"
if p != nil {
selected = p.SelectedCard
}
httpx.JSON(w, http.StatusOK, map[string]any{
"catalog": FullCatalog(),
"owned_cards": owned,
"selected_card": selected,
})
}
type cardReq struct {
CardID string `json:"card_id"`
}
// BuyCard — POST /api/shop/buy-card
func (h *Handler) BuyCard(w http.ResponseWriter, r *http.Request) {
id, ok := uid(r)
if !ok {
httpx.Error(w, http.StatusUnauthorized, "unauthorized")
return
}
var req cardReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpx.Error(w, http.StatusBadRequest, "invalid body")
return
}
switch err := h.svc.BuyCard(r.Context(), id, req.CardID); {
case err == nil:
httpx.JSON(w, http.StatusOK, map[string]string{"message": "ok"})
case errors.Is(err, ErrNotFound):
httpx.Error(w, http.StatusNotFound, "card not found")
case errors.Is(err, ErrAlreadyOwned):
httpx.Error(w, http.StatusConflict, "already owned")
case errors.Is(err, ErrInsufficient):
httpx.Error(w, http.StatusPaymentRequired, "insufficient coins")
default:
httpx.Error(w, http.StatusInternalServerError, "server error")
}
}
// SelectCard — POST /api/shop/select-card
func (h *Handler) SelectCard(w http.ResponseWriter, r *http.Request) {
id, ok := uid(r)
if !ok {
httpx.Error(w, http.StatusUnauthorized, "unauthorized")
return
}
var req cardReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpx.Error(w, http.StatusBadRequest, "invalid body")
return
}
switch err := h.svc.SelectCard(r.Context(), id, req.CardID); {
case err == nil:
httpx.JSON(w, http.StatusOK, map[string]string{"message": "ok"})
case errors.Is(err, ErrNotFound):
httpx.Error(w, http.StatusNotFound, "card not found")
case errors.Is(err, ErrNotOwned):
httpx.Error(w, http.StatusForbidden, "card not owned")
default:
httpx.Error(w, http.StatusInternalServerError, "server error")
}
}
// Purchase — POST /api/shop/purchase (تأیید خرید IAP)
func (h *Handler) Purchase(w http.ResponseWriter, r *http.Request) {
id, ok := uid(r)
if !ok {
httpx.Error(w, http.StatusUnauthorized, "unauthorized")
return
}
var req PurchaseRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpx.Error(w, http.StatusBadRequest, "invalid body")
return
}
switch err := h.svc.VerifyPurchase(r.Context(), id, req); {
case err == nil:
p, _ := h.svc.GetProfile(r.Context(), id)
httpx.JSON(w, http.StatusOK, map[string]any{"message": "ok", "wallet": p})
case errors.Is(err, ErrAlreadyOwned):
httpx.Error(w, http.StatusConflict, "already processed")
case errors.Is(err, ErrIAPInvalid):
httpx.Error(w, http.StatusBadRequest, "purchase not valid")
case errors.Is(err, ErrNotFound):
httpx.Error(w, http.StatusNotFound, "package not found")
default:
httpx.Error(w, http.StatusInternalServerError, "server error")
}
}
// Daily — POST /api/rewards/daily
func (h *Handler) Daily(w http.ResponseWriter, r *http.Request) {
id, ok := uid(r)
if !ok {
httpx.Error(w, http.StatusUnauthorized, "unauthorized")
return
}
switch amount, err := h.svc.ClaimDaily(r.Context(), id); {
case err == nil:
httpx.JSON(w, http.StatusOK, map[string]any{"message": "ok", "amount": amount})
case errors.Is(err, ErrTooSoon):
httpx.Error(w, http.StatusTooManyRequests, "already claimed today")
default:
httpx.Error(w, http.StatusInternalServerError, "server error")
}
}
type adReq struct {
Token string `json:"token"`
}
// AdReward — POST /api/rewards/ad (سکه رایگان پس از دیدن کامل تبلیغ)
func (h *Handler) AdReward(w http.ResponseWriter, r *http.Request) {
id, ok := uid(r)
if !ok {
httpx.Error(w, http.StatusUnauthorized, "unauthorized")
return
}
var req adReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpx.Error(w, http.StatusBadRequest, "invalid body")
return
}
switch amount, err := h.svc.ClaimAdReward(r.Context(), id, req.Token); {
case err == nil:
httpx.JSON(w, http.StatusOK, map[string]any{"message": "ok", "amount": amount})
case errors.Is(err, ErrAdNotVerified):
httpx.Error(w, http.StatusBadRequest, "ad not verified")
case errors.Is(err, ErrAdDuplicate):
httpx.Error(w, http.StatusConflict, "already claimed")
case errors.Is(err, ErrAdCap):
httpx.Error(w, http.StatusTooManyRequests, "daily ad cap reached")
default:
httpx.Error(w, http.StatusInternalServerError, "server error")
}
}
+139
View File
@@ -0,0 +1,139 @@
package economy
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
// AdVerifier تأیید می‌کند که کاربر یک تبلیغ rewarded را کامل دیده است.
type AdVerifier interface {
Verify(ctx context.Context, userID int64, token string) (bool, error)
}
// IAPVerifier تأیید می‌کند که یک خرید درون‌برنامه‌ای معتبر و پرداخت‌شده است.
type IAPVerifier interface {
Verify(ctx context.Context, store, productID, purchaseToken string) (bool, error)
}
// --- استاب توسعه: هر توکن غیرخالی را معتبر می‌داند (فقط برای محیط dev/تست) ---
type DevAdVerifier struct{}
func (DevAdVerifier) Verify(_ context.Context, _ int64, token string) (bool, error) {
return token != "", nil
}
type DevIAPVerifier struct{}
func (DevIAPVerifier) Verify(_ context.Context, _ string, _ string, token string) (bool, error) {
return token != "", nil
}
// --- آداپتر تپسل (تأیید سمت‌سرور تبلیغ rewarded) ---
//
// تپسل پس از پایان کامل تبلیغ، یک توکن/شناسه می‌دهد که سرور باید آن را
// نزد تپسل اعتبارسنجی کند. آدرس و قالب دقیق بسته به پنل تپسل تنظیم می‌شود؛
// اینجا اسکلت فراخوانی با کلید قرار داده شده است.
type TapsellAdVerifier struct {
APIKey string
BaseURL string // پیش‌فرض اگر خالی باشد ست می‌شود
client *http.Client
}
func NewTapsellAdVerifier(apiKey string) *TapsellAdVerifier {
return &TapsellAdVerifier{
APIKey: apiKey,
BaseURL: "https://api.tapsell.ir",
client: &http.Client{Timeout: 5 * time.Second},
}
}
func (t *TapsellAdVerifier) Verify(ctx context.Context, userID int64, token string) (bool, error) {
if t.APIKey == "" || token == "" {
return false, fmt.Errorf("tapsell: missing api key or token")
}
// TODO: مسیر دقیق اعتبارسنجی تپسل را مطابق مستندات پنل تنظیم کنید.
url := fmt.Sprintf("%s/rewarded/verify?token=%s", t.BaseURL, token)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return false, err
}
req.Header.Set("Authorization", t.APIKey)
resp, err := t.client.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
var out struct {
Valid bool `json:"valid"`
Completed bool `json:"completed"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return false, err
}
return out.Valid && out.Completed, nil
}
// --- آداپتر IAP کافه‌بازار / مایکت ---
//
// هر دو فروشگاه API شبیه Google Play دارند: با access_token (که از refresh_token
// گرفته می‌شود) وضعیت یک purchaseToken برای یک محصول را اعتبارسنجی می‌کنیم.
type StoreIAPVerifier struct {
// قالب آدرس اعتبارسنجی؛ %s ها به ترتیب: package, product, token
ValidateURLFmt string
PackageName string
AccessToken string // در عمل باید از refresh_token تازه‌سازی شود
client *http.Client
}
// NewBazaarVerifier آداپتر کافه‌بازار.
func NewBazaarVerifier(pkg, accessToken string) *StoreIAPVerifier {
return &StoreIAPVerifier{
ValidateURLFmt: "https://pardakht.cafebazaar.ir/devapi/v2/api/validate/%s/inapp/%s/purchases/%s/",
PackageName: pkg,
AccessToken: accessToken,
client: &http.Client{Timeout: 6 * time.Second},
}
}
// NewMyketVerifier آداپتر مایکت.
func NewMyketVerifier(pkg, accessToken string) *StoreIAPVerifier {
return &StoreIAPVerifier{
ValidateURLFmt: "https://developer.myket.ir/api/applications/%s/purchases/products/%s/tokens/%s/",
PackageName: pkg,
AccessToken: accessToken,
client: &http.Client{Timeout: 6 * time.Second},
}
}
func (v *StoreIAPVerifier) Verify(ctx context.Context, _ string, productID, purchaseToken string) (bool, error) {
if v.AccessToken == "" || purchaseToken == "" {
return false, fmt.Errorf("iap: missing access token or purchase token")
}
url := fmt.Sprintf(v.ValidateURLFmt, v.PackageName, productID, purchaseToken)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return false, err
}
req.Header.Set("Authorization", "Bearer "+v.AccessToken)
resp, err := v.client.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false, nil
}
var out struct {
// هر دو فروشگاه فیلدی شبیه purchaseState برمی‌گردانند (۰ = خریداری‌شده)
PurchaseState int `json:"purchaseState"`
ConsumptionState int `json:"consumptionState"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return false, err
}
return out.PurchaseState == 0, nil
}
+74
View File
@@ -0,0 +1,74 @@
package economy
import (
"context"
"database/sql"
"errors"
"fmt"
)
// ارزها.
const (
CurrencyCoin = "coin"
CurrencyTicket = "ticket"
)
var (
ErrInsufficient = errors.New("insufficient balance")
ErrBadCurrency = errors.New("invalid currency")
)
// column نام ستون موجودی را برای یک ارز برمی‌گرداند.
func column(currency string) (string, error) {
switch currency {
case CurrencyCoin:
return "coins", nil
case CurrencyTicket:
return "tickets", nil
}
return "", ErrBadCurrency
}
// adjustTx موجودی را داخل یک تراکنش تغییر داده و در دفتر ثبت می‌کند.
// برای delta منفی، اگر موجودی کافی نباشد ErrInsufficient برمی‌گرداند (بدون تغییر).
func adjustTx(ctx context.Context, tx *sql.Tx, userID int64, currency string, delta int64, reason, ref string) error {
col, err := column(currency)
if err != nil {
return err
}
var res sql.Result
if delta < 0 {
// فقط در صورت کفایت موجودی کم کن (اتمیک)
res, err = tx.ExecContext(ctx,
fmt.Sprintf("UPDATE users SET %s = %s + ? WHERE id = ? AND %s >= ?", col, col, col),
delta, userID, -delta)
} else {
res, err = tx.ExecContext(ctx,
fmt.Sprintf("UPDATE users SET %s = %s + ? WHERE id = ?", col, col),
delta, userID)
}
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrInsufficient
}
_, err = tx.ExecContext(ctx,
"INSERT INTO wallet_tx (user_id, amount, reason, ref, currency) VALUES (?, ?, ?, ?, ?)",
userID, delta, reason, ref, currency)
return err
}
// Adjust موجودی را در یک تراکنش مستقل تغییر می‌دهد.
func (s *Service) Adjust(ctx context.Context, userID int64, currency string, delta int64, reason, ref string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if err := adjustTx(ctx, tx, userID, currency, delta, reason, ref); err != nil {
return err
}
return tx.Commit()
}