feat: add uuid
This commit is contained in:
+30
-16
@@ -5,6 +5,7 @@ package admin
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
@@ -99,16 +101,16 @@ func (h *Handler) shop(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
data := map[string]any{
|
||||
"Nav": "shop",
|
||||
"Coins": h.rows(ctx, `SELECT id,title,coins,vip_days,price_toman,bonus_pct,sort,enabled FROM coin_packages ORDER BY sort`,
|
||||
"id", "title", "coins", "vip_days", "price_toman", "bonus_pct", "sort", "enabled"),
|
||||
"Tickets": h.rows(ctx, `SELECT id,title,tickets,price_toman,sort,enabled FROM ticket_packages ORDER BY sort`,
|
||||
"id", "title", "tickets", "price_toman", "sort", "enabled"),
|
||||
"Coins": h.rows(ctx, `SELECT id,title,coins,vip_days,price_toman,bonus_pct,sku,sort,enabled FROM coin_packages ORDER BY sort`,
|
||||
"id", "title", "coins", "vip_days", "price_toman", "bonus_pct", "sku", "sort", "enabled"),
|
||||
"Tickets": h.rows(ctx, `SELECT id,title,tickets,price_toman,sku,sort,enabled FROM ticket_packages ORDER BY sort`,
|
||||
"id", "title", "tickets", "price_toman", "sku", "sort", "enabled"),
|
||||
"Cards": h.rows(ctx, `SELECT id,title,price_coins,sort,enabled FROM card_skins ORDER BY sort`,
|
||||
"id", "title", "price_coins", "sort", "enabled"),
|
||||
"Boosters": h.rows(ctx, `SELECT id,title,multiplier,hours,price_toman,sort,enabled FROM boosters ORDER BY sort`,
|
||||
"id", "title", "multiplier", "hours", "price_toman", "sort", "enabled"),
|
||||
"VIP": h.rows(ctx, `SELECT id,title,months,price_toman,sort,enabled FROM vip_packages ORDER BY sort`,
|
||||
"id", "title", "months", "price_toman", "sort", "enabled"),
|
||||
"Boosters": h.rows(ctx, `SELECT id,title,multiplier,hours,price_toman,sku,sort,enabled FROM boosters ORDER BY sort`,
|
||||
"id", "title", "multiplier", "hours", "price_toman", "sku", "sort", "enabled"),
|
||||
"VIP": h.rows(ctx, `SELECT id,title,months,price_toman,sku,sort,enabled FROM vip_packages ORDER BY sort`,
|
||||
"id", "title", "months", "price_toman", "sku", "sort", "enabled"),
|
||||
"Tiers": h.rows(ctx, `SELECT id,title,hands,entry,prize,xp,trophy,rank_reward,sort,enabled FROM table_tiers ORDER BY sort`,
|
||||
"id", "title", "hands", "entry", "prize", "xp", "trophy", "rank_reward", "sort", "enabled"),
|
||||
"Saved": r.URL.Query().Get("saved") == "1",
|
||||
@@ -181,25 +183,26 @@ func (h *Handler) addItem(w http.ResponseWriter, r *http.Request) {
|
||||
if r.FormValue("enabled") == "on" {
|
||||
en = 1
|
||||
}
|
||||
sku := newUUID() // شناسهی محصولِ فروشگاه (uuid) خودکار تولید میشود
|
||||
|
||||
var q string
|
||||
var args []any
|
||||
switch kind {
|
||||
case "coin":
|
||||
q = `INSERT INTO coin_packages (id,title,coins,vip_days,price_toman,bonus_pct,sort,enabled) VALUES (?,?,?,?,?,?,?,?)`
|
||||
args = []any{id, r.FormValue("title"), f("coins"), f("vip_days"), f("price_toman"), f("bonus_pct"), f("sort"), en}
|
||||
q = `INSERT INTO coin_packages (id,title,coins,vip_days,price_toman,bonus_pct,sku,sort,enabled) VALUES (?,?,?,?,?,?,?,?,?)`
|
||||
args = []any{id, r.FormValue("title"), f("coins"), f("vip_days"), f("price_toman"), f("bonus_pct"), sku, f("sort"), en}
|
||||
case "ticket":
|
||||
q = `INSERT INTO ticket_packages (id,title,tickets,price_toman,sort,enabled) VALUES (?,?,?,?,?,?)`
|
||||
args = []any{id, r.FormValue("title"), f("tickets"), f("price_toman"), f("sort"), en}
|
||||
q = `INSERT INTO ticket_packages (id,title,tickets,price_toman,sku,sort,enabled) VALUES (?,?,?,?,?,?,?)`
|
||||
args = []any{id, r.FormValue("title"), f("tickets"), f("price_toman"), sku, f("sort"), en}
|
||||
case "card":
|
||||
q = `INSERT INTO card_skins (id,title,price_coins,sort,enabled) VALUES (?,?,?,?,?)`
|
||||
args = []any{id, r.FormValue("title"), f("price_coins"), f("sort"), en}
|
||||
case "booster":
|
||||
q = `INSERT INTO boosters (id,title,multiplier,hours,price_toman,sort,enabled) VALUES (?,?,?,?,?,?)`
|
||||
args = []any{id, r.FormValue("title"), f("multiplier"), f("hours"), f("price_toman"), f("sort"), en}
|
||||
q = `INSERT INTO boosters (id,title,multiplier,hours,price_toman,sku,sort,enabled) VALUES (?,?,?,?,?,?,?)`
|
||||
args = []any{id, r.FormValue("title"), f("multiplier"), f("hours"), f("price_toman"), sku, f("sort"), en}
|
||||
case "vip":
|
||||
q = `INSERT INTO vip_packages (id,title,months,price_toman,sort,enabled) VALUES (?,?,?,?,?,?)`
|
||||
args = []any{id, r.FormValue("title"), f("months"), f("price_toman"), f("sort"), en}
|
||||
q = `INSERT INTO vip_packages (id,title,months,price_toman,sku,sort,enabled) VALUES (?,?,?,?,?,?,?)`
|
||||
args = []any{id, r.FormValue("title"), f("months"), f("price_toman"), sku, f("sort"), en}
|
||||
case "tier":
|
||||
q = `INSERT INTO table_tiers (id,title,hands,entry,prize,xp,trophy,rank_reward,sort,enabled) VALUES (?,?,?,?,?,?,?,?,?,?)`
|
||||
args = []any{id, r.FormValue("title"), f("hands"), f("entry"), f("prize"), f("xp"), f("trophy"), f("rank_reward"), f("sort"), en}
|
||||
@@ -643,6 +646,17 @@ func (h *Handler) chatMessageDelete(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/admin/chat?saved=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// newUUID یک UUIDv4 برای شناسهی محصولِ فروشگاه (SKU) تولید میکند.
|
||||
func newUUID() string {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return fmt.Sprintf("sku-%d", time.Now().UnixNano())
|
||||
}
|
||||
b[6] = (b[6] & 0x0f) | 0x40 // نسخه ۴
|
||||
b[8] = (b[8] & 0x3f) | 0x80 // variant
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||
}
|
||||
|
||||
func (h *Handler) rows(ctx context.Context, query string, cols ...string) []map[string]any {
|
||||
rs, err := h.db.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
<h2>بستههای سکه</h2>
|
||||
<table>
|
||||
<tr><th>شناسه</th><th>عنوان</th><th>سکه</th><th>VIP (روز)</th><th>قیمت (تومان)</th><th>بونوس٪</th><th>ترتیب</th><th>فعال</th><th colspan="2"></th></tr>
|
||||
<tr><th>شناسه</th><th>عنوان</th><th>سکه</th><th>VIP (روز)</th><th>قیمت (تومان)</th><th>بونوس٪</th><th>SKU (مایکت/کافه)</th><th>ترتیب</th><th>فعال</th><th colspan="2"></th></tr>
|
||||
{{range .Coins}}
|
||||
<tr><form method="post" action="/admin/shop/coin">
|
||||
<td>{{.id}}<input type="hidden" name="id" value="{{.id}}"></td>
|
||||
@@ -19,6 +19,7 @@
|
||||
<td><input name="vip_days" value="{{.vip_days}}"></td>
|
||||
<td><input name="price_toman" value="{{.price_toman}}"></td>
|
||||
<td><input name="bonus_pct" value="{{.bonus_pct}}"></td>
|
||||
<td><code style="font-size:11px">{{.sku}}</code></td>
|
||||
<td><input name="sort" value="{{.sort}}"></td>
|
||||
<td><input type="checkbox" name="enabled" {{if .enabled}}checked{{end}}></td>
|
||||
<td><button>ذخیره</button></td>
|
||||
@@ -32,6 +33,7 @@
|
||||
<td><input name="vip_days" value="0"></td>
|
||||
<td><input name="price_toman" value="0"></td>
|
||||
<td><input name="bonus_pct" value="0"></td>
|
||||
<td style="color:#9aa;font-size:11px">خودکار</td>
|
||||
<td><input name="sort" value="0"></td>
|
||||
<td><input type="checkbox" name="enabled" checked></td>
|
||||
<td colspan="2"><button>افزودن</button></td>
|
||||
@@ -40,13 +42,14 @@
|
||||
|
||||
<h2>بستههای بلیط</h2>
|
||||
<table>
|
||||
<tr><th>شناسه</th><th>عنوان</th><th>بلیط</th><th>قیمت (تومان)</th><th>ترتیب</th><th>فعال</th><th colspan="2"></th></tr>
|
||||
<tr><th>شناسه</th><th>عنوان</th><th>بلیط</th><th>قیمت (تومان)</th><th>SKU (مایکت/کافه)</th><th>ترتیب</th><th>فعال</th><th colspan="2"></th></tr>
|
||||
{{range .Tickets}}
|
||||
<tr><form method="post" action="/admin/shop/ticket">
|
||||
<td>{{.id}}<input type="hidden" name="id" value="{{.id}}"></td>
|
||||
<td><input type="text" name="title" value="{{.title}}"></td>
|
||||
<td><input name="tickets" value="{{.tickets}}"></td>
|
||||
<td><input name="price_toman" value="{{.price_toman}}"></td>
|
||||
<td><code style="font-size:11px">{{.sku}}</code></td>
|
||||
<td><input name="sort" value="{{.sort}}"></td>
|
||||
<td><input type="checkbox" name="enabled" {{if .enabled}}checked{{end}}></td>
|
||||
<td><button>ذخیره</button></td>
|
||||
@@ -58,6 +61,7 @@
|
||||
<td><input name="title" placeholder="عنوان"></td>
|
||||
<td><input name="tickets" value="0"></td>
|
||||
<td><input name="price_toman" value="0"></td>
|
||||
<td style="color:#9aa;font-size:11px">خودکار</td>
|
||||
<td><input name="sort" value="0"></td>
|
||||
<td><input type="checkbox" name="enabled" checked></td>
|
||||
<td colspan="2"><button>افزودن</button></td>
|
||||
@@ -98,7 +102,7 @@
|
||||
|
||||
<h2>بوسترها</h2>
|
||||
<table>
|
||||
<tr><th>شناسه</th><th>عنوان</th><th>ضریب</th><th>ساعت</th><th>قیمت (تومان)</th><th>ترتیب</th><th>فعال</th><th colspan="2"></th></tr>
|
||||
<tr><th>شناسه</th><th>عنوان</th><th>ضریب</th><th>ساعت</th><th>قیمت (تومان)</th><th>SKU (مایکت/کافه)</th><th>ترتیب</th><th>فعال</th><th colspan="2"></th></tr>
|
||||
{{range .Boosters}}
|
||||
<tr><form method="post" action="/admin/shop/booster">
|
||||
<td>{{.id}}<input type="hidden" name="id" value="{{.id}}"></td>
|
||||
@@ -106,6 +110,7 @@
|
||||
<td><input name="multiplier" value="{{.multiplier}}"></td>
|
||||
<td><input name="hours" value="{{.hours}}"></td>
|
||||
<td><input name="price_toman" value="{{.price_toman}}"></td>
|
||||
<td><code style="font-size:11px">{{.sku}}</code></td>
|
||||
<td><input name="sort" value="{{.sort}}"></td>
|
||||
<td><input type="checkbox" name="enabled" {{if .enabled}}checked{{end}}></td>
|
||||
<td><button>ذخیره</button></td>
|
||||
@@ -118,6 +123,7 @@
|
||||
<td><input name="multiplier" value="2"></td>
|
||||
<td><input name="hours" value="24"></td>
|
||||
<td><input name="price_toman" value="0"></td>
|
||||
<td style="color:#9aa;font-size:11px">خودکار</td>
|
||||
<td><input name="sort" value="0"></td>
|
||||
<td><input type="checkbox" name="enabled" checked></td>
|
||||
<td colspan="2"><button>افزودن</button></td>
|
||||
@@ -126,13 +132,14 @@
|
||||
|
||||
<h2>اشتراکهای VIP</h2>
|
||||
<table>
|
||||
<tr><th>شناسه</th><th>عنوان</th><th>ماه</th><th>قیمت (تومان)</th><th>ترتیب</th><th>فعال</th><th colspan="2"></th></tr>
|
||||
<tr><th>شناسه</th><th>عنوان</th><th>ماه</th><th>قیمت (تومان)</th><th>SKU (مایکت/کافه)</th><th>ترتیب</th><th>فعال</th><th colspan="2"></th></tr>
|
||||
{{range .VIP}}
|
||||
<tr><form method="post" action="/admin/shop/vip">
|
||||
<td>{{.id}}<input type="hidden" name="id" value="{{.id}}"></td>
|
||||
<td><input type="text" name="title" value="{{.title}}"></td>
|
||||
<td><input name="months" value="{{.months}}"></td>
|
||||
<td><input name="price_toman" value="{{.price_toman}}"></td>
|
||||
<td><code style="font-size:11px">{{.sku}}</code></td>
|
||||
<td><input name="sort" value="{{.sort}}"></td>
|
||||
<td><input type="checkbox" name="enabled" {{if .enabled}}checked{{end}}></td>
|
||||
<td><button>ذخیره</button></td>
|
||||
@@ -144,6 +151,7 @@
|
||||
<td><input name="title" placeholder="عنوان"></td>
|
||||
<td><input name="months" value="1"></td>
|
||||
<td><input name="price_toman" value="0"></td>
|
||||
<td style="color:#9aa;font-size:11px">خودکار</td>
|
||||
<td><input name="sort" value="0"></td>
|
||||
<td><input type="checkbox" name="enabled" checked></td>
|
||||
<td colspan="2"><button>افزودن</button></td>
|
||||
|
||||
@@ -22,6 +22,10 @@ type Config struct {
|
||||
AdminPass string // پسوردِ پنل ادمین (Basic Auth)
|
||||
CardsDir string // پوشهی دیسکیِ اسکینهای آپلودی (روی volume ماندگار)
|
||||
CarpetsDir string // پوشهی دیسکیِ تصاویرِ فرش (روی volume ماندگار)
|
||||
// خرید درونبرنامهای (IAP)
|
||||
IAPPackage string // نامِ بستهی اپ (برای مایکت/کافه)
|
||||
MyketAccessToken string // access token مایکت (تأیید سمتسرور)
|
||||
BazaarRSAKey string // کلیدِ عمومیِ RSA کافهبازار (تأیید امضای آفلاین)
|
||||
}
|
||||
|
||||
// Load کانفیگ را از env با مقادیر پیشفرض معقول میخواند.
|
||||
@@ -41,6 +45,9 @@ func Load() Config {
|
||||
AdminPass: env("ADMIN_PANEL_PASS", "admin"),
|
||||
CardsDir: env("CARDS_DIR", "carddata"),
|
||||
CarpetsDir: env("CARPETS_DIR", "carpetdata"),
|
||||
IAPPackage: env("IAP_PACKAGE", ""),
|
||||
MyketAccessToken: env("MYKET_ACCESS_TOKEN", ""),
|
||||
BazaarRSAKey: env("BAZAAR_RSA_KEY", ""),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
-- شناسهی محصولِ فروشگاه (uuid) در پنلِ مایکت/کافهبازار برای تأییدِ خرید.
|
||||
ALTER TABLE coin_packages ADD COLUMN sku TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE ticket_packages ADD COLUMN sku TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE boosters ADD COLUMN sku TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE vip_packages ADD COLUMN sku TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- تولیدِ خودکارِ uuid برای محصولاتِ موجود (شناسهی یکتا برای پنلِ مایکت/کافه).
|
||||
UPDATE coin_packages SET sku = lower(hex(randomblob(16))) WHERE sku = '';
|
||||
UPDATE ticket_packages SET sku = lower(hex(randomblob(16))) WHERE sku = '';
|
||||
UPDATE boosters SET sku = lower(hex(randomblob(16))) WHERE sku = '';
|
||||
UPDATE vip_packages SET sku = lower(hex(randomblob(16))) WHERE sku = '';
|
||||
Reference in New Issue
Block a user