feat: add uuid

This commit is contained in:
2026-07-04 21:21:13 +03:30
parent 63716576ca
commit c6997477b9
10 changed files with 320 additions and 78 deletions
+5
View File
@@ -8,6 +8,7 @@ const (
)
// CoinPackage بسته‌ی سکه با پول واقعی (IAP) — ممکن است VIP هم بدهد.
// SKU شناسه‌ی محصول در پنلِ مایکت/کافه‌بازار (uuid) است.
type CoinPackage struct {
ID string `json:"id"`
Title string `json:"title"`
@@ -15,6 +16,7 @@ type CoinPackage struct {
VIPDays int `json:"vip_days"`
PriceToman int `json:"price_toman"`
BonusPct int `json:"bonus_pct"`
SKU string `json:"sku"`
}
// TicketPackage بسته‌ی بلیط با پول واقعی (IAP).
@@ -23,6 +25,7 @@ type TicketPackage struct {
Title string `json:"title"`
Tickets int64 `json:"tickets"`
PriceToman int `json:"price_toman"`
SKU string `json:"sku"`
}
// CardSkin اسکین کارت که با سکه باز می‌شود (قیمت ۰ یعنی پیش‌فرض رایگان).
@@ -39,6 +42,7 @@ type Booster struct {
Multiplier int `json:"multiplier"`
Hours int `json:"hours"`
PriceToman int `json:"price_toman"`
SKU string `json:"sku"`
}
// VIPPackage بسته‌ی اشتراک VIP با پول واقعی (IAP).
@@ -47,6 +51,7 @@ type VIPPackage struct {
Title string `json:"title"`
Months int `json:"months"`
PriceToman int `json:"price_toman"`
SKU string `json:"sku"`
}
// TableTier نوع میز: تعداد دست، ورودی، جایزه، XP و جام.
+8 -8
View File
@@ -8,13 +8,13 @@ func (s *Service) LoadCatalog(ctx context.Context) error {
var c Catalog
rows, err := s.db.QueryContext(ctx,
`SELECT id, title, coins, vip_days, price_toman, bonus_pct FROM coin_packages WHERE enabled = 1 ORDER BY sort`)
`SELECT id, title, coins, vip_days, price_toman, bonus_pct, sku FROM coin_packages WHERE enabled = 1 ORDER BY sort`)
if err != nil {
return err
}
for rows.Next() {
var p CoinPackage
if err := rows.Scan(&p.ID, &p.Title, &p.Coins, &p.VIPDays, &p.PriceToman, &p.BonusPct); err != nil {
if err := rows.Scan(&p.ID, &p.Title, &p.Coins, &p.VIPDays, &p.PriceToman, &p.BonusPct, &p.SKU); err != nil {
rows.Close()
return err
}
@@ -23,13 +23,13 @@ func (s *Service) LoadCatalog(ctx context.Context) error {
rows.Close()
rows, err = s.db.QueryContext(ctx,
`SELECT id, title, tickets, price_toman FROM ticket_packages WHERE enabled = 1 ORDER BY sort`)
`SELECT id, title, tickets, price_toman, sku FROM ticket_packages WHERE enabled = 1 ORDER BY sort`)
if err != nil {
return err
}
for rows.Next() {
var p TicketPackage
if err := rows.Scan(&p.ID, &p.Title, &p.Tickets, &p.PriceToman); err != nil {
if err := rows.Scan(&p.ID, &p.Title, &p.Tickets, &p.PriceToman, &p.SKU); err != nil {
rows.Close()
return err
}
@@ -53,13 +53,13 @@ func (s *Service) LoadCatalog(ctx context.Context) error {
rows.Close()
rows, err = s.db.QueryContext(ctx,
`SELECT id, title, multiplier, hours, price_toman FROM boosters WHERE enabled = 1 ORDER BY sort`)
`SELECT id, title, multiplier, hours, price_toman, sku FROM boosters WHERE enabled = 1 ORDER BY sort`)
if err != nil {
return err
}
for rows.Next() {
var p Booster
if err := rows.Scan(&p.ID, &p.Title, &p.Multiplier, &p.Hours, &p.PriceToman); err != nil {
if err := rows.Scan(&p.ID, &p.Title, &p.Multiplier, &p.Hours, &p.PriceToman, &p.SKU); err != nil {
rows.Close()
return err
}
@@ -68,13 +68,13 @@ func (s *Service) LoadCatalog(ctx context.Context) error {
rows.Close()
rows, err = s.db.QueryContext(ctx,
`SELECT id, title, months, price_toman FROM vip_packages WHERE enabled = 1 ORDER BY sort`)
`SELECT id, title, months, price_toman, sku FROM vip_packages WHERE enabled = 1 ORDER BY sort`)
if err != nil {
return err
}
for rows.Next() {
var p VIPPackage
if err := rows.Scan(&p.ID, &p.Title, &p.Months, &p.PriceToman); err != nil {
if err := rows.Scan(&p.ID, &p.Title, &p.Months, &p.PriceToman, &p.SKU); err != nil {
rows.Close()
return err
}
+25 -9
View File
@@ -248,27 +248,28 @@ func (s *Service) addXPTx(ctx context.Context, tx *sql.Tx, userID int64, base in
type PurchaseRequest struct {
Store string `json:"store"` // bazaar | myket
Kind string `json:"kind"` // coin | ticket | booster | vip
ProductID string `json:"product_id"` // شناسه بسته در کاتالوگ
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 {
ok, err := s.iap.Verify(ctx, req.Store, req.ProductID, req.Token)
if err != nil || !ok {
return ErrIAPInvalid
}
// ابتدا بسته را از کاتالوگ می‌یابیم تا پاداش و 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 = p.Coins, p.VIPDays
coins, vipDays, sku = p.Coins, p.VIPDays, p.SKU
if s.isVIP(ctx, userID) {
coins += coins / 10 // پاداش ۱۰٪ VIP
}
@@ -277,21 +278,36 @@ func (s *Service) VerifyPurchase(ctx context.Context, userID int64, req Purchase
if p == nil {
return ErrNotFound
}
tickets = p.Tickets
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 = p.Months * 30 // هر ماه = ۳۰ روز
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 {
+130 -38
View File
@@ -2,9 +2,16 @@ package economy
import (
"context"
"crypto"
"crypto/rsa"
"crypto/sha1"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"net/http"
"strings"
"time"
)
@@ -13,9 +20,20 @@ type AdVerifier interface {
Verify(ctx context.Context, userID int64, token string) (bool, error)
}
// IAPProof داده‌های لازم برای تأیید یک خرید درون‌برنامه‌ای.
// برای مایکت: Store/ProductID/Token کافی است (تأیید سمت‌سرور با access token).
// برای کافه‌بازار: PurchaseData + Signature برای تأیید امضای RSA آفلاین.
type IAPProof struct {
Store string
ProductID string
Token string
PurchaseData string
Signature string
}
// IAPVerifier تأیید می‌کند که یک خرید درون‌برنامه‌ای معتبر و پرداخت‌شده است.
type IAPVerifier interface {
Verify(ctx context.Context, store, productID, purchaseToken string) (bool, error)
Verify(ctx context.Context, p IAPProof) (bool, error)
}
// --- استاب توسعه: هر توکن غیرخالی را معتبر می‌داند (فقط برای محیط dev/تست) ---
@@ -28,8 +46,8 @@ func (DevAdVerifier) Verify(_ context.Context, _ int64, token string) (bool, err
type DevIAPVerifier struct{}
func (DevIAPVerifier) Verify(_ context.Context, _ string, _ string, token string) (bool, error) {
return token != "", nil
func (DevIAPVerifier) Verify(_ context.Context, p IAPProof) (bool, error) {
return p.Token != "", nil
}
// --- آداپتر تپسل (تأیید سمت‌سرور تبلیغ rewarded) ---
@@ -77,63 +95,137 @@ func (t *TapsellAdVerifier) Verify(ctx context.Context, userID int64, token stri
return out.Valid && out.Completed, nil
}
// --- آداپتر IAP کافه‌بازار / مایکت ---
// --- مایکت: تأیید سمت‌سرور با X-Access-Token ---
//
// هر دو فروشگاه 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
// GET https://developer.myket.ir/api/applications/{pkg}/purchases/products/{sku}/tokens/{token}
// هدر: X-Access-Token: <accessToken> . پاسخ purchaseState=0 یعنی معتبر (غیرِ صفر=مسترد).
type MyketVerifier struct {
PackageName string
AccessToken string
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},
func NewMyketVerifier(pkg, accessToken string) *MyketVerifier {
return &MyketVerifier{
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 *MyketVerifier) Verify(ctx context.Context, p IAPProof) (bool, error) {
if v.AccessToken == "" || p.Token == "" {
return false, fmt.Errorf("myket: missing access token or purchase token")
}
}
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)
url := fmt.Sprintf(
"https://developer.myket.ir/api/applications/%s/purchases/products/%s/tokens/%s",
v.PackageName, p.ProductID, p.Token)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return false, err
}
req.Header.Set("Authorization", "Bearer "+v.AccessToken)
req.Header.Set("X-Access-Token", 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
return false, nil // ۴۰۴/۴۰۱ ⇒ توکنِ نامعتبر
}
var out struct {
// هر دو فروشگاه فیلدی شبیه purchaseState برمی‌گردانند (۰ = خریداری‌شده)
PurchaseState int `json:"purchaseState"`
ConsumptionState int `json:"consumptionState"`
PurchaseState int `json:"purchaseState"` // ۰=خریداری‌شده، غیرِصفر=مسترد
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return false, err
}
return out.PurchaseState == 0, nil
}
// --- کافه‌بازار: تأیید امضای RSA آفلاین (فقط کلید عمومی لازم است) ---
//
// کلاینت purchaseData (متنِ خامِ JSON) و signature (base64) را می‌فرستد؛ صحتِ امضا
// با کلیدِ عمومیِ RSA و الگوریتم SHA1withRSA (مانند Google Play/Bazaar) بررسی می‌شود.
type BazaarRSAVerifier struct {
pub *rsa.PublicKey
}
// NewBazaarRSAVerifier کلیدِ عمومی را از رشته‌ی base64 (DER/PKIX) یا PEM می‌پذیرد.
func NewBazaarRSAVerifier(publicKey string) (*BazaarRSAVerifier, error) {
pub, err := parseRSAPublicKey(publicKey)
if err != nil {
return nil, err
}
return &BazaarRSAVerifier{pub: pub}, nil
}
func (v *BazaarRSAVerifier) Verify(_ context.Context, p IAPProof) (bool, error) {
if p.PurchaseData == "" || p.Signature == "" {
return false, fmt.Errorf("bazaar: missing purchase data or signature")
}
sig, err := base64.StdEncoding.DecodeString(p.Signature)
if err != nil {
return false, fmt.Errorf("bazaar: bad signature encoding: %w", err)
}
h := sha1.Sum([]byte(p.PurchaseData))
if err := rsa.VerifyPKCS1v15(v.pub, crypto.SHA1, h[:], sig); err != nil {
return false, nil // امضای نامعتبر ⇒ خریدِ نامعتبر (نه خطای سرور)
}
return true, nil
}
// parseRSAPublicKey کلیدِ عمومیِ RSA را از PEM یا base64ِ خامِ DER (خروجیِ کنسولِ
// توسعه‌دهنده) استخراج می‌کند.
func parseRSAPublicKey(key string) (*rsa.PublicKey, error) {
key = strings.TrimSpace(key)
var der []byte
if strings.Contains(key, "BEGIN") {
block, _ := pem.Decode([]byte(key))
if block == nil {
return nil, fmt.Errorf("rsa: invalid PEM")
}
der = block.Bytes
} else {
clean := strings.NewReplacer("\n", "", "\r", "", " ", "").Replace(key)
b, err := base64.StdEncoding.DecodeString(clean)
if err != nil {
return nil, fmt.Errorf("rsa: invalid base64 key: %w", err)
}
der = b
}
pub, err := x509.ParsePKIXPublicKey(der)
if err != nil {
return nil, fmt.Errorf("rsa: parse key: %w", err)
}
rsaPub, ok := pub.(*rsa.PublicKey)
if !ok {
return nil, fmt.Errorf("rsa: not an RSA public key")
}
return rsaPub, nil
}
// StoreRouter بر اساس store، تأیید را به مایکت یا کافه‌بازار می‌سپارد.
// اگر فروشگاهی پیکربندی نشده باشد و devFallback روشن باشد، از DevIAPVerifier
// استفاده می‌شود (برای توسعه/تست).
type StoreRouter struct {
Myket IAPVerifier
Bazaar IAPVerifier
DevFallback bool
}
func (r StoreRouter) Verify(ctx context.Context, p IAPProof) (bool, error) {
var v IAPVerifier
switch p.Store {
case "myket":
v = r.Myket
case "bazaar", "cafe", "cafebazaar":
v = r.Bazaar
}
if v == nil {
if r.DevFallback {
return DevIAPVerifier{}.Verify(ctx, p)
}
return false, fmt.Errorf("iap: store %q not configured", p.Store)
}
return v.Verify(ctx, p)
}
+75
View File
@@ -0,0 +1,75 @@
package economy
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha1"
"crypto/x509"
"encoding/base64"
"testing"
)
// TestBazaarRSAVerify امضای معتبر را می‌پذیرد و امضای/دیتای دستکاری‌شده را رد می‌کند.
func TestBazaarRSAVerify(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
der, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
if err != nil {
t.Fatal(err)
}
pubB64 := base64.StdEncoding.EncodeToString(der) // مثلِ خروجیِ کنسولِ کافه‌بازار
v, err := NewBazaarRSAVerifier(pubB64)
if err != nil {
t.Fatalf("new verifier: %v", err)
}
data := `{"orderId":"1","productId":"coin_small","purchaseToken":"tok123","purchaseState":0}`
h := sha1.Sum([]byte(data))
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA1, h[:])
if err != nil {
t.Fatal(err)
}
sigB64 := base64.StdEncoding.EncodeToString(sig)
ctx := context.Background()
ok, err := v.Verify(ctx, IAPProof{PurchaseData: data, Signature: sigB64})
if err != nil || !ok {
t.Fatalf("valid signature rejected: ok=%v err=%v", ok, err)
}
// دیتای دستکاری‌شده ⇒ رد
ok, _ = v.Verify(ctx, IAPProof{PurchaseData: data + " ", Signature: sigB64})
if ok {
t.Fatal("tampered data accepted")
}
// امضای دستکاری‌شده ⇒ رد
bad := base64.StdEncoding.EncodeToString([]byte("not-a-real-signature"))
ok, _ = v.Verify(ctx, IAPProof{PurchaseData: data, Signature: bad})
if ok {
t.Fatal("bad signature accepted")
}
}
// TestStoreRouter مسیر‌دهی بر اساس store و fallback را بررسی می‌کند.
func TestStoreRouter(t *testing.T) {
ctx := context.Background()
// بدونِ پیکربندی و بدونِ fallback ⇒ خطا.
r := StoreRouter{}
if ok, err := r.Verify(ctx, IAPProof{Store: "myket", Token: "x"}); ok || err == nil {
t.Fatal("unconfigured store should error")
}
// با devFallback ⇒ توکنِ غیرخالی معتبر است.
r = StoreRouter{DevFallback: true}
if ok, err := r.Verify(ctx, IAPProof{Store: "bazaar", Token: "x"}); err != nil || !ok {
t.Fatalf("dev fallback failed: ok=%v err=%v", ok, err)
}
}