init
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user