feat: add uuid
This commit is contained in:
+17
-3
@@ -40,9 +40,23 @@ func main() {
|
|||||||
jwt := auth.NewJWT(cfg.JWTSecret, cfg.JWTTTL)
|
jwt := auth.NewJWT(cfg.JWTSecret, cfg.JWTTTL)
|
||||||
authH := auth.NewHandler(users, otp, kave, jwt, cfg.AdminMobile, cfg.AdminOTP)
|
authH := auth.NewHandler(users, otp, kave, jwt, cfg.AdminMobile, cfg.AdminOTP)
|
||||||
|
|
||||||
// اقتصاد و فروشگاه. تأییدکنندهها فعلاً نسخهی توسعهاند؛ آداپتر واقعی
|
// اقتصاد و فروشگاه. تأییدِ خرید: مایکت (X-Access-Token) و کافهبازار (امضای RSA).
|
||||||
// تپسل (تبلیغ) و بازار/مایکت (IAP) با تنظیم creds جایگزین میشوند.
|
// اگر کلیدها تنظیم نشده باشند، به تأییدکنندهی توسعه برمیگردیم (dev fallback).
|
||||||
eco := economy.New(st.DB, economy.DevAdVerifier{}, economy.DevIAPVerifier{})
|
iap := economy.StoreRouter{DevFallback: true}
|
||||||
|
if cfg.MyketAccessToken != "" && cfg.IAPPackage != "" {
|
||||||
|
iap.Myket = economy.NewMyketVerifier(cfg.IAPPackage, cfg.MyketAccessToken)
|
||||||
|
slog.Info("IAP myket configured", "package", cfg.IAPPackage)
|
||||||
|
}
|
||||||
|
if cfg.BazaarRSAKey != "" {
|
||||||
|
bv, err := economy.NewBazaarRSAVerifier(cfg.BazaarRSAKey)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("bazaar rsa key", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
iap.Bazaar = bv
|
||||||
|
slog.Info("IAP bazaar (RSA) configured")
|
||||||
|
}
|
||||||
|
eco := economy.New(st.DB, economy.DevAdVerifier{}, iap)
|
||||||
if err := eco.LoadCatalog(context.Background()); err != nil {
|
if err := eco.LoadCatalog(context.Background()); err != nil {
|
||||||
slog.Error("load catalog", "err", err)
|
slog.Error("load catalog", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
+30
-16
@@ -5,6 +5,7 @@ package admin
|
|||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -15,6 +16,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
@@ -99,16 +101,16 @@ func (h *Handler) shop(w http.ResponseWriter, r *http.Request) {
|
|||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
"Nav": "shop",
|
"Nav": "shop",
|
||||||
"Coins": h.rows(ctx, `SELECT id,title,coins,vip_days,price_toman,bonus_pct,sort,enabled FROM coin_packages ORDER BY sort`,
|
"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", "sort", "enabled"),
|
"id", "title", "coins", "vip_days", "price_toman", "bonus_pct", "sku", "sort", "enabled"),
|
||||||
"Tickets": h.rows(ctx, `SELECT id,title,tickets,price_toman,sort,enabled FROM ticket_packages ORDER BY sort`,
|
"Tickets": h.rows(ctx, `SELECT id,title,tickets,price_toman,sku,sort,enabled FROM ticket_packages ORDER BY sort`,
|
||||||
"id", "title", "tickets", "price_toman", "sort", "enabled"),
|
"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`,
|
"Cards": h.rows(ctx, `SELECT id,title,price_coins,sort,enabled FROM card_skins ORDER BY sort`,
|
||||||
"id", "title", "price_coins", "sort", "enabled"),
|
"id", "title", "price_coins", "sort", "enabled"),
|
||||||
"Boosters": h.rows(ctx, `SELECT id,title,multiplier,hours,price_toman,sort,enabled FROM boosters ORDER BY sort`,
|
"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", "sort", "enabled"),
|
"id", "title", "multiplier", "hours", "price_toman", "sku", "sort", "enabled"),
|
||||||
"VIP": h.rows(ctx, `SELECT id,title,months,price_toman,sort,enabled FROM vip_packages ORDER BY sort`,
|
"VIP": h.rows(ctx, `SELECT id,title,months,price_toman,sku,sort,enabled FROM vip_packages ORDER BY sort`,
|
||||||
"id", "title", "months", "price_toman", "sort", "enabled"),
|
"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`,
|
"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"),
|
"id", "title", "hands", "entry", "prize", "xp", "trophy", "rank_reward", "sort", "enabled"),
|
||||||
"Saved": r.URL.Query().Get("saved") == "1",
|
"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" {
|
if r.FormValue("enabled") == "on" {
|
||||||
en = 1
|
en = 1
|
||||||
}
|
}
|
||||||
|
sku := newUUID() // شناسهی محصولِ فروشگاه (uuid) خودکار تولید میشود
|
||||||
|
|
||||||
var q string
|
var q string
|
||||||
var args []any
|
var args []any
|
||||||
switch kind {
|
switch kind {
|
||||||
case "coin":
|
case "coin":
|
||||||
q = `INSERT INTO coin_packages (id,title,coins,vip_days,price_toman,bonus_pct,sort,enabled) VALUES (?,?,?,?,?,?,?,?)`
|
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"), f("sort"), en}
|
args = []any{id, r.FormValue("title"), f("coins"), f("vip_days"), f("price_toman"), f("bonus_pct"), sku, f("sort"), en}
|
||||||
case "ticket":
|
case "ticket":
|
||||||
q = `INSERT INTO ticket_packages (id,title,tickets,price_toman,sort,enabled) VALUES (?,?,?,?,?,?)`
|
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"), f("sort"), en}
|
args = []any{id, r.FormValue("title"), f("tickets"), f("price_toman"), sku, f("sort"), en}
|
||||||
case "card":
|
case "card":
|
||||||
q = `INSERT INTO card_skins (id,title,price_coins,sort,enabled) VALUES (?,?,?,?,?)`
|
q = `INSERT INTO card_skins (id,title,price_coins,sort,enabled) VALUES (?,?,?,?,?)`
|
||||||
args = []any{id, r.FormValue("title"), f("price_coins"), f("sort"), en}
|
args = []any{id, r.FormValue("title"), f("price_coins"), f("sort"), en}
|
||||||
case "booster":
|
case "booster":
|
||||||
q = `INSERT INTO boosters (id,title,multiplier,hours,price_toman,sort,enabled) VALUES (?,?,?,?,?,?)`
|
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"), f("sort"), en}
|
args = []any{id, r.FormValue("title"), f("multiplier"), f("hours"), f("price_toman"), sku, f("sort"), en}
|
||||||
case "vip":
|
case "vip":
|
||||||
q = `INSERT INTO vip_packages (id,title,months,price_toman,sort,enabled) VALUES (?,?,?,?,?,?)`
|
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"), f("sort"), en}
|
args = []any{id, r.FormValue("title"), f("months"), f("price_toman"), sku, f("sort"), en}
|
||||||
case "tier":
|
case "tier":
|
||||||
q = `INSERT INTO table_tiers (id,title,hands,entry,prize,xp,trophy,rank_reward,sort,enabled) VALUES (?,?,?,?,?,?,?,?,?,?)`
|
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}
|
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)
|
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 {
|
func (h *Handler) rows(ctx context.Context, query string, cols ...string) []map[string]any {
|
||||||
rs, err := h.db.QueryContext(ctx, query)
|
rs, err := h.db.QueryContext(ctx, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
<h2>بستههای سکه</h2>
|
<h2>بستههای سکه</h2>
|
||||||
<table>
|
<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}}
|
{{range .Coins}}
|
||||||
<tr><form method="post" action="/admin/shop/coin">
|
<tr><form method="post" action="/admin/shop/coin">
|
||||||
<td>{{.id}}<input type="hidden" name="id" value="{{.id}}"></td>
|
<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="vip_days" value="{{.vip_days}}"></td>
|
||||||
<td><input name="price_toman" value="{{.price_toman}}"></td>
|
<td><input name="price_toman" value="{{.price_toman}}"></td>
|
||||||
<td><input name="bonus_pct" value="{{.bonus_pct}}"></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 name="sort" value="{{.sort}}"></td>
|
||||||
<td><input type="checkbox" name="enabled" {{if .enabled}}checked{{end}}></td>
|
<td><input type="checkbox" name="enabled" {{if .enabled}}checked{{end}}></td>
|
||||||
<td><button>ذخیره</button></td>
|
<td><button>ذخیره</button></td>
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
<td><input name="vip_days" value="0"></td>
|
<td><input name="vip_days" value="0"></td>
|
||||||
<td><input name="price_toman" value="0"></td>
|
<td><input name="price_toman" value="0"></td>
|
||||||
<td><input name="bonus_pct" 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 name="sort" value="0"></td>
|
||||||
<td><input type="checkbox" name="enabled" checked></td>
|
<td><input type="checkbox" name="enabled" checked></td>
|
||||||
<td colspan="2"><button>افزودن</button></td>
|
<td colspan="2"><button>افزودن</button></td>
|
||||||
@@ -40,13 +42,14 @@
|
|||||||
|
|
||||||
<h2>بستههای بلیط</h2>
|
<h2>بستههای بلیط</h2>
|
||||||
<table>
|
<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}}
|
{{range .Tickets}}
|
||||||
<tr><form method="post" action="/admin/shop/ticket">
|
<tr><form method="post" action="/admin/shop/ticket">
|
||||||
<td>{{.id}}<input type="hidden" name="id" value="{{.id}}"></td>
|
<td>{{.id}}<input type="hidden" name="id" value="{{.id}}"></td>
|
||||||
<td><input type="text" name="title" value="{{.title}}"></td>
|
<td><input type="text" name="title" value="{{.title}}"></td>
|
||||||
<td><input name="tickets" value="{{.tickets}}"></td>
|
<td><input name="tickets" value="{{.tickets}}"></td>
|
||||||
<td><input name="price_toman" value="{{.price_toman}}"></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 name="sort" value="{{.sort}}"></td>
|
||||||
<td><input type="checkbox" name="enabled" {{if .enabled}}checked{{end}}></td>
|
<td><input type="checkbox" name="enabled" {{if .enabled}}checked{{end}}></td>
|
||||||
<td><button>ذخیره</button></td>
|
<td><button>ذخیره</button></td>
|
||||||
@@ -58,6 +61,7 @@
|
|||||||
<td><input name="title" placeholder="عنوان"></td>
|
<td><input name="title" placeholder="عنوان"></td>
|
||||||
<td><input name="tickets" value="0"></td>
|
<td><input name="tickets" value="0"></td>
|
||||||
<td><input name="price_toman" 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 name="sort" value="0"></td>
|
||||||
<td><input type="checkbox" name="enabled" checked></td>
|
<td><input type="checkbox" name="enabled" checked></td>
|
||||||
<td colspan="2"><button>افزودن</button></td>
|
<td colspan="2"><button>افزودن</button></td>
|
||||||
@@ -98,7 +102,7 @@
|
|||||||
|
|
||||||
<h2>بوسترها</h2>
|
<h2>بوسترها</h2>
|
||||||
<table>
|
<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}}
|
{{range .Boosters}}
|
||||||
<tr><form method="post" action="/admin/shop/booster">
|
<tr><form method="post" action="/admin/shop/booster">
|
||||||
<td>{{.id}}<input type="hidden" name="id" value="{{.id}}"></td>
|
<td>{{.id}}<input type="hidden" name="id" value="{{.id}}"></td>
|
||||||
@@ -106,6 +110,7 @@
|
|||||||
<td><input name="multiplier" value="{{.multiplier}}"></td>
|
<td><input name="multiplier" value="{{.multiplier}}"></td>
|
||||||
<td><input name="hours" value="{{.hours}}"></td>
|
<td><input name="hours" value="{{.hours}}"></td>
|
||||||
<td><input name="price_toman" value="{{.price_toman}}"></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 name="sort" value="{{.sort}}"></td>
|
||||||
<td><input type="checkbox" name="enabled" {{if .enabled}}checked{{end}}></td>
|
<td><input type="checkbox" name="enabled" {{if .enabled}}checked{{end}}></td>
|
||||||
<td><button>ذخیره</button></td>
|
<td><button>ذخیره</button></td>
|
||||||
@@ -118,6 +123,7 @@
|
|||||||
<td><input name="multiplier" value="2"></td>
|
<td><input name="multiplier" value="2"></td>
|
||||||
<td><input name="hours" value="24"></td>
|
<td><input name="hours" value="24"></td>
|
||||||
<td><input name="price_toman" 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 name="sort" value="0"></td>
|
||||||
<td><input type="checkbox" name="enabled" checked></td>
|
<td><input type="checkbox" name="enabled" checked></td>
|
||||||
<td colspan="2"><button>افزودن</button></td>
|
<td colspan="2"><button>افزودن</button></td>
|
||||||
@@ -126,13 +132,14 @@
|
|||||||
|
|
||||||
<h2>اشتراکهای VIP</h2>
|
<h2>اشتراکهای VIP</h2>
|
||||||
<table>
|
<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}}
|
{{range .VIP}}
|
||||||
<tr><form method="post" action="/admin/shop/vip">
|
<tr><form method="post" action="/admin/shop/vip">
|
||||||
<td>{{.id}}<input type="hidden" name="id" value="{{.id}}"></td>
|
<td>{{.id}}<input type="hidden" name="id" value="{{.id}}"></td>
|
||||||
<td><input type="text" name="title" value="{{.title}}"></td>
|
<td><input type="text" name="title" value="{{.title}}"></td>
|
||||||
<td><input name="months" value="{{.months}}"></td>
|
<td><input name="months" value="{{.months}}"></td>
|
||||||
<td><input name="price_toman" value="{{.price_toman}}"></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 name="sort" value="{{.sort}}"></td>
|
||||||
<td><input type="checkbox" name="enabled" {{if .enabled}}checked{{end}}></td>
|
<td><input type="checkbox" name="enabled" {{if .enabled}}checked{{end}}></td>
|
||||||
<td><button>ذخیره</button></td>
|
<td><button>ذخیره</button></td>
|
||||||
@@ -144,6 +151,7 @@
|
|||||||
<td><input name="title" placeholder="عنوان"></td>
|
<td><input name="title" placeholder="عنوان"></td>
|
||||||
<td><input name="months" value="1"></td>
|
<td><input name="months" value="1"></td>
|
||||||
<td><input name="price_toman" 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 name="sort" value="0"></td>
|
||||||
<td><input type="checkbox" name="enabled" checked></td>
|
<td><input type="checkbox" name="enabled" checked></td>
|
||||||
<td colspan="2"><button>افزودن</button></td>
|
<td colspan="2"><button>افزودن</button></td>
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ type Config struct {
|
|||||||
AdminPass string // پسوردِ پنل ادمین (Basic Auth)
|
AdminPass string // پسوردِ پنل ادمین (Basic Auth)
|
||||||
CardsDir string // پوشهی دیسکیِ اسکینهای آپلودی (روی volume ماندگار)
|
CardsDir string // پوشهی دیسکیِ اسکینهای آپلودی (روی volume ماندگار)
|
||||||
CarpetsDir string // پوشهی دیسکیِ تصاویرِ فرش (روی volume ماندگار)
|
CarpetsDir string // پوشهی دیسکیِ تصاویرِ فرش (روی volume ماندگار)
|
||||||
|
// خرید درونبرنامهای (IAP)
|
||||||
|
IAPPackage string // نامِ بستهی اپ (برای مایکت/کافه)
|
||||||
|
MyketAccessToken string // access token مایکت (تأیید سمتسرور)
|
||||||
|
BazaarRSAKey string // کلیدِ عمومیِ RSA کافهبازار (تأیید امضای آفلاین)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load کانفیگ را از env با مقادیر پیشفرض معقول میخواند.
|
// Load کانفیگ را از env با مقادیر پیشفرض معقول میخواند.
|
||||||
@@ -41,6 +45,9 @@ func Load() Config {
|
|||||||
AdminPass: env("ADMIN_PANEL_PASS", "admin"),
|
AdminPass: env("ADMIN_PANEL_PASS", "admin"),
|
||||||
CardsDir: env("CARDS_DIR", "carddata"),
|
CardsDir: env("CARDS_DIR", "carddata"),
|
||||||
CarpetsDir: env("CARPETS_DIR", "carpetdata"),
|
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 هم بدهد.
|
// CoinPackage بستهی سکه با پول واقعی (IAP) — ممکن است VIP هم بدهد.
|
||||||
|
// SKU شناسهی محصول در پنلِ مایکت/کافهبازار (uuid) است.
|
||||||
type CoinPackage struct {
|
type CoinPackage struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
@@ -15,6 +16,7 @@ type CoinPackage struct {
|
|||||||
VIPDays int `json:"vip_days"`
|
VIPDays int `json:"vip_days"`
|
||||||
PriceToman int `json:"price_toman"`
|
PriceToman int `json:"price_toman"`
|
||||||
BonusPct int `json:"bonus_pct"`
|
BonusPct int `json:"bonus_pct"`
|
||||||
|
SKU string `json:"sku"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TicketPackage بستهی بلیط با پول واقعی (IAP).
|
// TicketPackage بستهی بلیط با پول واقعی (IAP).
|
||||||
@@ -23,6 +25,7 @@ type TicketPackage struct {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Tickets int64 `json:"tickets"`
|
Tickets int64 `json:"tickets"`
|
||||||
PriceToman int `json:"price_toman"`
|
PriceToman int `json:"price_toman"`
|
||||||
|
SKU string `json:"sku"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CardSkin اسکین کارت که با سکه باز میشود (قیمت ۰ یعنی پیشفرض رایگان).
|
// CardSkin اسکین کارت که با سکه باز میشود (قیمت ۰ یعنی پیشفرض رایگان).
|
||||||
@@ -39,6 +42,7 @@ type Booster struct {
|
|||||||
Multiplier int `json:"multiplier"`
|
Multiplier int `json:"multiplier"`
|
||||||
Hours int `json:"hours"`
|
Hours int `json:"hours"`
|
||||||
PriceToman int `json:"price_toman"`
|
PriceToman int `json:"price_toman"`
|
||||||
|
SKU string `json:"sku"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// VIPPackage بستهی اشتراک VIP با پول واقعی (IAP).
|
// VIPPackage بستهی اشتراک VIP با پول واقعی (IAP).
|
||||||
@@ -47,6 +51,7 @@ type VIPPackage struct {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Months int `json:"months"`
|
Months int `json:"months"`
|
||||||
PriceToman int `json:"price_toman"`
|
PriceToman int `json:"price_toman"`
|
||||||
|
SKU string `json:"sku"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableTier نوع میز: تعداد دست، ورودی، جایزه، XP و جام.
|
// TableTier نوع میز: تعداد دست، ورودی، جایزه، XP و جام.
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ func (s *Service) LoadCatalog(ctx context.Context) error {
|
|||||||
var c Catalog
|
var c Catalog
|
||||||
|
|
||||||
rows, err := s.db.QueryContext(ctx,
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p CoinPackage
|
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()
|
rows.Close()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -23,13 +23,13 @@ func (s *Service) LoadCatalog(ctx context.Context) error {
|
|||||||
rows.Close()
|
rows.Close()
|
||||||
|
|
||||||
rows, err = s.db.QueryContext(ctx,
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p TicketPackage
|
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()
|
rows.Close()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -53,13 +53,13 @@ func (s *Service) LoadCatalog(ctx context.Context) error {
|
|||||||
rows.Close()
|
rows.Close()
|
||||||
|
|
||||||
rows, err = s.db.QueryContext(ctx,
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p Booster
|
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()
|
rows.Close()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -68,13 +68,13 @@ func (s *Service) LoadCatalog(ctx context.Context) error {
|
|||||||
rows.Close()
|
rows.Close()
|
||||||
|
|
||||||
rows, err = s.db.QueryContext(ctx,
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p VIPPackage
|
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()
|
rows.Close()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -248,27 +248,28 @@ func (s *Service) addXPTx(ctx context.Context, tx *sql.Tx, userID int64, base in
|
|||||||
type PurchaseRequest struct {
|
type PurchaseRequest struct {
|
||||||
Store string `json:"store"` // bazaar | myket
|
Store string `json:"store"` // bazaar | myket
|
||||||
Kind string `json:"kind"` // coin | ticket | booster | vip
|
Kind string `json:"kind"` // coin | ticket | booster | vip
|
||||||
ProductID string `json:"product_id"` // شناسه بسته در کاتالوگ
|
ProductID string `json:"product_id"` // شناسه بسته در کاتالوگ (SKU فروشگاه)
|
||||||
Token string `json:"token"` // purchaseToken
|
Token string `json:"token"` // purchaseToken
|
||||||
|
// برای تأیید امضای آفلاین (کافهبازار): متنِ خامِ خرید و امضای RSA (base64).
|
||||||
|
PurchaseData string `json:"purchase_data"`
|
||||||
|
Signature string `json:"signature"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// VerifyPurchase خرید را تأیید و در صورت معتبر بودن اعتبار/بلیط/VIP/بوستر میدهد (idempotent).
|
// VerifyPurchase خرید را تأیید و در صورت معتبر بودن اعتبار/بلیط/VIP/بوستر میدهد (idempotent).
|
||||||
func (s *Service) VerifyPurchase(ctx context.Context, userID int64, req PurchaseRequest) error {
|
func (s *Service) VerifyPurchase(ctx context.Context, userID int64, req PurchaseRequest) error {
|
||||||
ok, err := s.iap.Verify(ctx, req.Store, req.ProductID, req.Token)
|
// ابتدا بسته را از کاتالوگ مییابیم تا پاداش و SKU (شناسهی محصول در فروشگاه)
|
||||||
if err != nil || !ok {
|
// را بهدست آوریم؛ سپس با همان SKU خرید را تأیید میکنیم.
|
||||||
return ErrIAPInvalid
|
|
||||||
}
|
|
||||||
|
|
||||||
var coins, tickets int64
|
var coins, tickets int64
|
||||||
var vipDays int
|
var vipDays int
|
||||||
var booster *Booster
|
var booster *Booster
|
||||||
|
var sku string
|
||||||
switch req.Kind {
|
switch req.Kind {
|
||||||
case "coin":
|
case "coin":
|
||||||
p := s.findCoinPackage(req.ProductID)
|
p := s.findCoinPackage(req.ProductID)
|
||||||
if p == nil {
|
if p == nil {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
coins, vipDays = p.Coins, p.VIPDays
|
coins, vipDays, sku = p.Coins, p.VIPDays, p.SKU
|
||||||
if s.isVIP(ctx, userID) {
|
if s.isVIP(ctx, userID) {
|
||||||
coins += coins / 10 // پاداش ۱۰٪ VIP
|
coins += coins / 10 // پاداش ۱۰٪ VIP
|
||||||
}
|
}
|
||||||
@@ -277,21 +278,36 @@ func (s *Service) VerifyPurchase(ctx context.Context, userID int64, req Purchase
|
|||||||
if p == nil {
|
if p == nil {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
tickets = p.Tickets
|
tickets, sku = p.Tickets, p.SKU
|
||||||
case "booster":
|
case "booster":
|
||||||
booster = s.findBooster(req.ProductID)
|
booster = s.findBooster(req.ProductID)
|
||||||
if booster == nil {
|
if booster == nil {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
|
sku = booster.SKU
|
||||||
case "vip":
|
case "vip":
|
||||||
p := s.findVIPPackage(req.ProductID)
|
p := s.findVIPPackage(req.ProductID)
|
||||||
if p == nil {
|
if p == nil {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
vipDays = p.Months * 30 // هر ماه = ۳۰ روز
|
vipDays, sku = p.Months*30, p.SKU // هر ماه = ۳۰ روز
|
||||||
default:
|
default:
|
||||||
return ErrNotFound
|
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)
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+125
-33
@@ -2,9 +2,16 @@ package economy
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,9 +20,20 @@ type AdVerifier interface {
|
|||||||
Verify(ctx context.Context, userID int64, token string) (bool, error)
|
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 تأیید میکند که یک خرید درونبرنامهای معتبر و پرداختشده است.
|
// IAPVerifier تأیید میکند که یک خرید درونبرنامهای معتبر و پرداختشده است.
|
||||||
type IAPVerifier interface {
|
type IAPVerifier interface {
|
||||||
Verify(ctx context.Context, store, productID, purchaseToken string) (bool, error)
|
Verify(ctx context.Context, p IAPProof) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- استاب توسعه: هر توکن غیرخالی را معتبر میداند (فقط برای محیط dev/تست) ---
|
// --- استاب توسعه: هر توکن غیرخالی را معتبر میداند (فقط برای محیط dev/تست) ---
|
||||||
@@ -28,8 +46,8 @@ func (DevAdVerifier) Verify(_ context.Context, _ int64, token string) (bool, err
|
|||||||
|
|
||||||
type DevIAPVerifier struct{}
|
type DevIAPVerifier struct{}
|
||||||
|
|
||||||
func (DevIAPVerifier) Verify(_ context.Context, _ string, _ string, token string) (bool, error) {
|
func (DevIAPVerifier) Verify(_ context.Context, p IAPProof) (bool, error) {
|
||||||
return token != "", nil
|
return p.Token != "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- آداپتر تپسل (تأیید سمتسرور تبلیغ rewarded) ---
|
// --- آداپتر تپسل (تأیید سمتسرور تبلیغ rewarded) ---
|
||||||
@@ -77,63 +95,137 @@ func (t *TapsellAdVerifier) Verify(ctx context.Context, userID int64, token stri
|
|||||||
return out.Valid && out.Completed, nil
|
return out.Valid && out.Completed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- آداپتر IAP کافهبازار / مایکت ---
|
// --- مایکت: تأیید سمتسرور با X-Access-Token ---
|
||||||
//
|
//
|
||||||
// هر دو فروشگاه API شبیه Google Play دارند: با access_token (که از refresh_token
|
// GET https://developer.myket.ir/api/applications/{pkg}/purchases/products/{sku}/tokens/{token}
|
||||||
// گرفته میشود) وضعیت یک purchaseToken برای یک محصول را اعتبارسنجی میکنیم.
|
// هدر: X-Access-Token: <accessToken> . پاسخ purchaseState=0 یعنی معتبر (غیرِ صفر=مسترد).
|
||||||
type StoreIAPVerifier struct {
|
type MyketVerifier struct {
|
||||||
// قالب آدرس اعتبارسنجی؛ %s ها به ترتیب: package, product, token
|
|
||||||
ValidateURLFmt string
|
|
||||||
PackageName string
|
PackageName string
|
||||||
AccessToken string // در عمل باید از refresh_token تازهسازی شود
|
AccessToken string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewBazaarVerifier آداپتر کافهبازار.
|
func NewMyketVerifier(pkg, accessToken string) *MyketVerifier {
|
||||||
func NewBazaarVerifier(pkg, accessToken string) *StoreIAPVerifier {
|
return &MyketVerifier{
|
||||||
return &StoreIAPVerifier{
|
|
||||||
ValidateURLFmt: "https://pardakht.cafebazaar.ir/devapi/v2/api/validate/%s/inapp/%s/purchases/%s/",
|
|
||||||
PackageName: pkg,
|
PackageName: pkg,
|
||||||
AccessToken: accessToken,
|
AccessToken: accessToken,
|
||||||
client: &http.Client{Timeout: 6 * time.Second},
|
client: &http.Client{Timeout: 6 * time.Second},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMyketVerifier آداپتر مایکت.
|
func (v *MyketVerifier) Verify(ctx context.Context, p IAPProof) (bool, error) {
|
||||||
func NewMyketVerifier(pkg, accessToken string) *StoreIAPVerifier {
|
if v.AccessToken == "" || p.Token == "" {
|
||||||
return &StoreIAPVerifier{
|
return false, fmt.Errorf("myket: missing access token or purchase token")
|
||||||
ValidateURLFmt: "https://developer.myket.ir/api/applications/%s/purchases/products/%s/tokens/%s/",
|
|
||||||
PackageName: pkg,
|
|
||||||
AccessToken: accessToken,
|
|
||||||
client: &http.Client{Timeout: 6 * time.Second},
|
|
||||||
}
|
}
|
||||||
}
|
url := fmt.Sprintf(
|
||||||
|
"https://developer.myket.ir/api/applications/%s/purchases/products/%s/tokens/%s",
|
||||||
func (v *StoreIAPVerifier) Verify(ctx context.Context, _ string, productID, purchaseToken string) (bool, error) {
|
v.PackageName, p.ProductID, p.Token)
|
||||||
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)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "Bearer "+v.AccessToken)
|
req.Header.Set("X-Access-Token", v.AccessToken)
|
||||||
resp, err := v.client.Do(req)
|
resp, err := v.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return false, nil
|
return false, nil // ۴۰۴/۴۰۱ ⇒ توکنِ نامعتبر
|
||||||
}
|
}
|
||||||
var out struct {
|
var out struct {
|
||||||
// هر دو فروشگاه فیلدی شبیه purchaseState برمیگردانند (۰ = خریداریشده)
|
PurchaseState int `json:"purchaseState"` // ۰=خریداریشده، غیرِصفر=مسترد
|
||||||
PurchaseState int `json:"purchaseState"`
|
|
||||||
ConsumptionState int `json:"consumptionState"`
|
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
return out.PurchaseState == 0, nil
|
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