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
+17 -3
View File
@@ -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
View File
@@ -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 {
+12 -4
View File
@@ -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>
+7
View File
@@ -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", ""),
} }
} }
+5
View File
@@ -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 -8
View File
@@ -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
} }
+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 { 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
View File
@@ -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)
}
+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)
}
}
+11
View File
@@ -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 = '';