feat: add carpet
@@ -11,7 +11,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/server ./cmd/serv
|
|||||||
FROM gcr.io/distroless/static-debian12
|
FROM gcr.io/distroless/static-debian12
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /out/server /app/server
|
COPY --from=build /out/server /app/server
|
||||||
ENV ADDR=:8080 DB_PATH=/data/hakemsho.db CARDS_DIR=/data/cards
|
ENV ADDR=:8080 DB_PATH=/data/hakemsho.db CARDS_DIR=/data/cards CARPETS_DIR=/data/carpets
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
VOLUME ["/data"]
|
VOLUME ["/data"]
|
||||||
USER nonroot:nonroot
|
USER nonroot:nonroot
|
||||||
|
|||||||
@@ -83,9 +83,11 @@ func main() {
|
|||||||
|
|
||||||
// تصاویرِ کارت (هر اسکین یک پوشه) — از backend سرویس میشود تا اپ سبک بماند.
|
// تصاویرِ کارت (هر اسکین یک پوشه) — از backend سرویس میشود تا اپ سبک بماند.
|
||||||
r.Handle("/cards/*", static.CardsHandler(cfg.CardsDir))
|
r.Handle("/cards/*", static.CardsHandler(cfg.CardsDir))
|
||||||
|
// تصاویرِ فرش (بهجای میز) — از پنل ادمین آپلود میشوند.
|
||||||
|
r.Handle("/carpets/*", static.CarpetsHandler(cfg.CarpetsDir))
|
||||||
|
|
||||||
// پنل ادمین (HTML سرورساید، پشت Basic Auth)
|
// پنل ادمین (HTML سرورساید، پشت Basic Auth)
|
||||||
r.Mount("/admin", admin.New(st.DB, eco, cfg.CardsDir).Routes(cfg.AdminUser, cfg.AdminPass))
|
r.Mount("/admin", admin.New(st.DB, eco, cfg.CardsDir, cfg.CarpetsDir).Routes(cfg.AdminUser, cfg.AdminPass))
|
||||||
|
|
||||||
r.Route("/api", func(r chi.Router) {
|
r.Route("/api", func(r chi.Router) {
|
||||||
r.Route("/auth", func(r chi.Router) {
|
r.Route("/auth", func(r chi.Router) {
|
||||||
@@ -108,6 +110,9 @@ func main() {
|
|||||||
r.Post("/shop/select-card", ecoH.SelectCard)
|
r.Post("/shop/select-card", ecoH.SelectCard)
|
||||||
r.Get("/chat-packs", ecoH.ChatPacks)
|
r.Get("/chat-packs", ecoH.ChatPacks)
|
||||||
r.Post("/shop/buy-chat-pack", ecoH.BuyChatPack)
|
r.Post("/shop/buy-chat-pack", ecoH.BuyChatPack)
|
||||||
|
r.Get("/carpets", ecoH.Carpets)
|
||||||
|
r.Post("/shop/buy-carpet", ecoH.BuyCarpet)
|
||||||
|
r.Post("/shop/select-carpet", ecoH.SelectCarpet)
|
||||||
r.Post("/shop/purchase", ecoH.Purchase)
|
r.Post("/shop/purchase", ecoH.Purchase)
|
||||||
r.Post("/rewards/daily", ecoH.Daily)
|
r.Post("/rewards/daily", ecoH.Daily)
|
||||||
r.Post("/rewards/ad", ecoH.AdReward)
|
r.Post("/rewards/ad", ecoH.AdReward)
|
||||||
|
|||||||
@@ -26,15 +26,16 @@ import (
|
|||||||
var tplFS embed.FS
|
var tplFS embed.FS
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
eco *economy.Service
|
eco *economy.Service
|
||||||
tpl *template.Template
|
tpl *template.Template
|
||||||
cardsDir string // پوشهی دیسکیِ آپلودِ اسکینها (همان مسیری که static از آن سرو میکند)
|
cardsDir string // پوشهی دیسکیِ آپلودِ اسکینها (همان مسیری که static از آن سرو میکند)
|
||||||
|
carpetsDir string // پوشهی دیسکیِ آپلودِ تصاویرِ فرش
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(db *sql.DB, eco *economy.Service, cardsDir string) *Handler {
|
func New(db *sql.DB, eco *economy.Service, cardsDir, carpetsDir string) *Handler {
|
||||||
tpl := template.Must(template.ParseFS(tplFS, "templates/*.html"))
|
tpl := template.Must(template.ParseFS(tplFS, "templates/*.html"))
|
||||||
return &Handler{db: db, eco: eco, tpl: tpl, cardsDir: cardsDir}
|
return &Handler{db: db, eco: eco, tpl: tpl, cardsDir: cardsDir, carpetsDir: carpetsDir}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Routes زیرروترِ /admin را با Basic Auth برمیگرداند.
|
// Routes زیرروترِ /admin را با Basic Auth برمیگرداند.
|
||||||
@@ -51,10 +52,15 @@ func (h *Handler) Routes(user, pass string) http.Handler {
|
|||||||
r.Post("/users/coins", h.adjustCoins)
|
r.Post("/users/coins", h.adjustCoins)
|
||||||
r.Post("/users/grant", h.grantToUser)
|
r.Post("/users/grant", h.grantToUser)
|
||||||
r.Post("/season/reset", h.resetSeason)
|
r.Post("/season/reset", h.resetSeason)
|
||||||
|
r.Get("/carpets", h.carpetsAdmin)
|
||||||
|
r.Post("/carpets/add", h.carpetAdd)
|
||||||
|
r.Post("/carpets/delete", h.carpetDelete)
|
||||||
|
r.Post("/carpets/{id}/upload", h.carpetUpload)
|
||||||
r.Get("/chat", h.chatAdmin)
|
r.Get("/chat", h.chatAdmin)
|
||||||
r.Post("/chat/pack/add", h.chatPackAdd)
|
r.Post("/chat/pack/add", h.chatPackAdd)
|
||||||
r.Post("/chat/pack/delete", h.chatPackDelete)
|
r.Post("/chat/pack/delete", h.chatPackDelete)
|
||||||
r.Post("/chat/message/add", h.chatMessageAdd)
|
r.Post("/chat/message/add", h.chatMessageAdd)
|
||||||
|
r.Post("/chat/message", h.chatMessageUpdate)
|
||||||
r.Post("/chat/message/delete", h.chatMessageDelete)
|
r.Post("/chat/message/delete", h.chatMessageDelete)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
@@ -443,6 +449,86 @@ func (h *Handler) grantToUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// rows یک کوئری را به []map[string]any تبدیل میکند (برای رندرِ عمومیِ جدولها).
|
// rows یک کوئری را به []map[string]any تبدیل میکند (برای رندرِ عمومیِ جدولها).
|
||||||
|
// --- مدیریتِ فرشها ---
|
||||||
|
|
||||||
|
func (h *Handler) carpetsAdmin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
carpets := h.rows(ctx,
|
||||||
|
`SELECT id,title,price_coins,vip,sort,enabled FROM carpets ORDER BY sort`,
|
||||||
|
"id", "title", "price_coins", "vip", "sort", "enabled")
|
||||||
|
h.render(w, "carpets.html", map[string]any{
|
||||||
|
"Nav": "carpets",
|
||||||
|
"Carpets": carpets,
|
||||||
|
"Saved": r.URL.Query().Get("saved") == "1",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) carpetAdd(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = r.ParseForm()
|
||||||
|
id := r.FormValue("id")
|
||||||
|
if id == "" || strings.ContainsAny(id, "/\\.") {
|
||||||
|
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
num := func(k string) int { v, _ := strconv.Atoi(r.FormValue(k)); return v }
|
||||||
|
vip := 0
|
||||||
|
if r.FormValue("vip") == "on" {
|
||||||
|
vip = 1
|
||||||
|
}
|
||||||
|
_, err := h.db.ExecContext(r.Context(),
|
||||||
|
`INSERT OR REPLACE INTO carpets (id,title,price_coins,vip,sort,enabled) VALUES (?,?,?,?,?,1)`,
|
||||||
|
id, r.FormValue("title"), num("price_coins"), vip, num("sort"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/admin/carpets?saved=1", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) carpetDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = r.ParseForm()
|
||||||
|
_, _ = h.db.ExecContext(r.Context(), `DELETE FROM carpets WHERE id=?`, r.FormValue("id"))
|
||||||
|
http.Redirect(w, r, "/admin/carpets?saved=1", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// carpetUpload یک تصویرِ jpg/png را برای یک فرش آپلود و با نامِ <id>.jpg ذخیره میکند.
|
||||||
|
func (h *Handler) carpetUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
if id == "" || strings.ContainsAny(id, "/\\.") {
|
||||||
|
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.carpetsDir == "" {
|
||||||
|
http.Error(w, "CARPETS_DIR تنظیم نشده است", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseMultipartForm(16 << 20); err != nil {
|
||||||
|
http.Error(w, "bad upload", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, _, err := r.FormFile("image")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "image لازم است", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
if err := os.MkdirAll(h.carpetsDir, 0o755); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := os.Create(filepath.Join(h.carpetsDir, id+".jpg"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
if _, err := io.Copy(out, io.LimitReader(file, 16<<20)); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/admin/carpets?saved=1", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
// --- مدیریتِ بستههای چت و پیامها ---
|
// --- مدیریتِ بستههای چت و پیامها ---
|
||||||
|
|
||||||
func (h *Handler) chatAdmin(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) chatAdmin(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -533,6 +619,23 @@ func (h *Handler) chatMessageAdd(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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) chatMessageUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = r.ParseForm()
|
||||||
|
id, _ := strconv.Atoi(r.FormValue("id"))
|
||||||
|
body := r.FormValue("body")
|
||||||
|
if id == 0 || body == "" {
|
||||||
|
http.Redirect(w, r, "/admin/chat", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sort, _ := strconv.Atoi(r.FormValue("sort"))
|
||||||
|
if _, err := h.db.ExecContext(r.Context(),
|
||||||
|
`UPDATE chat_messages SET body=?, sort=? WHERE id=?`, body, sort, id); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/admin/chat?saved=1", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) chatMessageDelete(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) chatMessageDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
_ = r.ParseForm()
|
_ = r.ParseForm()
|
||||||
id, _ := strconv.Atoi(r.FormValue("id"))
|
id, _ := strconv.Atoi(r.FormValue("id"))
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html dir="rtl" lang="fa">
|
||||||
|
{{template "head" .}}
|
||||||
|
<body>
|
||||||
|
{{template "nav" .}}
|
||||||
|
<div class="wrap">
|
||||||
|
<h1>فرشها</h1>
|
||||||
|
{{if .Saved}}<div class="saved">انجام شد ✓</div>{{end}}
|
||||||
|
|
||||||
|
<p style="color:#9aa;font-size:13px">
|
||||||
|
هر فرش بهجای میزِ بازی استفاده میشود. تصویرِ هر فرش را بهصورتِ jpg آپلود کنید
|
||||||
|
(با نامِ شناسه ذخیره میشود). شناسهٔ <code>classic</code> یعنی میزِ پیشفرضِ بدونِ تصویر.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr><th>شناسه</th><th>عنوان</th><th>قیمت (سکه)</th><th>VIP رایگان</th><th>ترتیب</th><th>پیشنمایش</th><th>تصویر</th><th></th></tr>
|
||||||
|
{{range .Carpets}}
|
||||||
|
<tr><form method="post" action="/admin/carpets/add">
|
||||||
|
<td>{{.id}}<input type="hidden" name="id" value="{{.id}}"></td>
|
||||||
|
<td><input name="title" value="{{.title}}"></td>
|
||||||
|
<td><input name="price_coins" value="{{.price_coins}}" style="width:80px"></td>
|
||||||
|
<td><input type="checkbox" name="vip" {{if .vip}}checked{{end}}></td>
|
||||||
|
<td><input name="sort" value="{{.sort}}" style="width:50px"></td>
|
||||||
|
<td><img src="/carpets/{{.id}}.jpg" alt="—" style="height:48px;border-radius:4px" onerror="this.style.display='none'"></td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/admin/carpets/{{.id}}/upload" enctype="multipart/form-data" style="display:flex;gap:4px">
|
||||||
|
<input type="file" name="image" accept="image/*" required>
|
||||||
|
<button>آپلود</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button>ذخیره</button>
|
||||||
|
<button formaction="/admin/carpets/delete" formnovalidate class="del">حذف</button>
|
||||||
|
</td>
|
||||||
|
</form></tr>
|
||||||
|
{{end}}
|
||||||
|
<tr class="addrow"><form method="post" action="/admin/carpets/add">
|
||||||
|
<td><input name="id" placeholder="شناسه" required></td>
|
||||||
|
<td><input name="title" placeholder="عنوان"></td>
|
||||||
|
<td><input name="price_coins" value="0" style="width:80px"></td>
|
||||||
|
<td><input type="checkbox" name="vip"></td>
|
||||||
|
<td><input name="sort" value="0" style="width:50px"></td>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td><button>افزودن فرش</button></td>
|
||||||
|
</form></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -50,12 +50,12 @@
|
|||||||
<table>
|
<table>
|
||||||
<tr><th>متن / شکلک</th><th>ترتیب</th><th></th></tr>
|
<tr><th>متن / شکلک</th><th>ترتیب</th><th></th></tr>
|
||||||
{{range .messages}}
|
{{range .messages}}
|
||||||
<tr><form method="post" action="/admin/chat/message/delete">
|
<tr><form method="post" action="/admin/chat/message">
|
||||||
<td>{{.body}}</td>
|
<td><input type="hidden" name="id" value="{{.id}}"><input name="body" value="{{.body}}" style="width:100%"></td>
|
||||||
<td>{{.sort}}</td>
|
<td><input name="sort" value="{{.sort}}" style="width:50px"></td>
|
||||||
<td>
|
<td>
|
||||||
<input type="hidden" name="id" value="{{.id}}">
|
<button>ذخیره</button>
|
||||||
<button class="del">حذف</button>
|
<button formaction="/admin/chat/message/delete" formnovalidate class="del">حذف</button>
|
||||||
</td>
|
</td>
|
||||||
</form></tr>
|
</form></tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
<nav>
|
<nav>
|
||||||
<a href="/admin" class="{{if eq .Nav "dashboard"}}active{{end}}">داشبورد</a>
|
<a href="/admin" class="{{if eq .Nav "dashboard"}}active{{end}}">داشبورد</a>
|
||||||
<a href="/admin/shop" class="{{if eq .Nav "shop"}}active{{end}}">فروشگاه</a>
|
<a href="/admin/shop" class="{{if eq .Nav "shop"}}active{{end}}">فروشگاه</a>
|
||||||
|
<a href="/admin/carpets" class="{{if eq .Nav "carpets"}}active{{end}}">فرشها</a>
|
||||||
<a href="/admin/chat" class="{{if eq .Nav "chat"}}active{{end}}">چت</a>
|
<a href="/admin/chat" class="{{if eq .Nav "chat"}}active{{end}}">چت</a>
|
||||||
<a href="/admin/users" class="{{if eq .Nav "users"}}active{{end}}">کاربران</a>
|
<a href="/admin/users" class="{{if eq .Nav "users"}}active{{end}}">کاربران</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type Config struct {
|
|||||||
AdminUser string // یوزرنیمِ پنل ادمین (Basic Auth)
|
AdminUser string // یوزرنیمِ پنل ادمین (Basic Auth)
|
||||||
AdminPass string // پسوردِ پنل ادمین (Basic Auth)
|
AdminPass string // پسوردِ پنل ادمین (Basic Auth)
|
||||||
CardsDir string // پوشهی دیسکیِ اسکینهای آپلودی (روی volume ماندگار)
|
CardsDir string // پوشهی دیسکیِ اسکینهای آپلودی (روی volume ماندگار)
|
||||||
|
CarpetsDir string // پوشهی دیسکیِ تصاویرِ فرش (روی volume ماندگار)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load کانفیگ را از env با مقادیر پیشفرض معقول میخواند.
|
// Load کانفیگ را از env با مقادیر پیشفرض معقول میخواند.
|
||||||
@@ -39,6 +40,7 @@ func Load() Config {
|
|||||||
AdminUser: env("ADMIN_PANEL_USER", "admin"),
|
AdminUser: env("ADMIN_PANEL_USER", "admin"),
|
||||||
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"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package economy
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// Carpet یک فرشِ ایرانی که بهجای میزِ بازی استفاده میشود. Owned یعنی قابلِ انتخاب.
|
||||||
|
type Carpet struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
PriceCoins int64 `json:"price_coins"`
|
||||||
|
VIP bool `json:"vip"`
|
||||||
|
Owned bool `json:"owned"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carpets فرشهای فعال را با وضعیتِ مالکیتِ کاربر برمیگرداند.
|
||||||
|
func (s *Service) Carpets(ctx context.Context, userID int64) ([]Carpet, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx,
|
||||||
|
`SELECT id, title, price_coins, vip FROM carpets WHERE enabled = 1 ORDER BY sort`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var carpets []Carpet
|
||||||
|
for rows.Next() {
|
||||||
|
var c Carpet
|
||||||
|
var vip int
|
||||||
|
if err := rows.Scan(&c.ID, &c.Title, &c.PriceCoins, &vip); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.VIP = vip == 1
|
||||||
|
carpets = append(carpets, c)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
owned, err := s.ownedCarpets(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
isVIP := s.isVIP(ctx, userID)
|
||||||
|
for i := range carpets {
|
||||||
|
c := &carpets[i]
|
||||||
|
c.Owned = (c.PriceCoins == 0 && !c.VIP) || (c.VIP && isVIP) || owned[c.ID]
|
||||||
|
}
|
||||||
|
return carpets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ownedCarpets(ctx context.Context, userID int64) (map[string]bool, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx,
|
||||||
|
`SELECT carpet_id FROM user_carpets WHERE user_id = ?`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
owned := map[string]bool{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
owned[id] = true
|
||||||
|
}
|
||||||
|
return owned, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuyCarpet یک فرش را با سکه باز میکند.
|
||||||
|
func (s *Service) BuyCarpet(ctx context.Context, userID int64, carpetID string) error {
|
||||||
|
var price int64
|
||||||
|
var vip, enabled int
|
||||||
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT price_coins, vip, enabled FROM carpets WHERE id = ?`, carpetID).
|
||||||
|
Scan(&price, &vip, &enabled)
|
||||||
|
if err != nil || enabled != 1 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
var exists int
|
||||||
|
_ = tx.QueryRowContext(ctx,
|
||||||
|
`SELECT 1 FROM user_carpets WHERE user_id = ? AND carpet_id = ?`, userID, carpetID).Scan(&exists)
|
||||||
|
if exists == 1 {
|
||||||
|
return ErrAlreadyOwned
|
||||||
|
}
|
||||||
|
if price > 0 {
|
||||||
|
if err := adjustTx(ctx, tx, userID, CurrencyCoin, -price, "buy_carpet", carpetID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO user_carpets (user_id, carpet_id) VALUES (?, ?)`, userID, carpetID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectCarpet فرشِ انتخابیِ کاربر را تنظیم میکند (باید مالکش باشد یا رایگان/VIP).
|
||||||
|
func (s *Service) SelectCarpet(ctx context.Context, userID int64, carpetID string) error {
|
||||||
|
carpets, err := s.Carpets(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, c := range carpets {
|
||||||
|
if c.ID == carpetID {
|
||||||
|
if !c.Owned {
|
||||||
|
return ErrNotOwned
|
||||||
|
}
|
||||||
|
_, err := s.db.ExecContext(ctx,
|
||||||
|
`UPDATE users SET selected_carpet = ? WHERE id = ?`, carpetID, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
@@ -55,7 +55,8 @@ type Profile struct {
|
|||||||
XPNeed int64 `json:"xp_for_next"`
|
XPNeed int64 `json:"xp_for_next"`
|
||||||
VIP bool `json:"vip"`
|
VIP bool `json:"vip"`
|
||||||
VIPUntil *string `json:"vip_until"`
|
VIPUntil *string `json:"vip_until"`
|
||||||
SelectedCard string `json:"selected_card"`
|
SelectedCard string `json:"selected_card"`
|
||||||
|
SelectedCarpet string `json:"selected_carpet"`
|
||||||
RankPoints int64 `json:"rank_points"`
|
RankPoints int64 `json:"rank_points"`
|
||||||
RankTier string `json:"rank_tier"` // bronze..king
|
RankTier string `json:"rank_tier"` // bronze..king
|
||||||
RankIndex int `json:"rank_index"` // ۰..۴
|
RankIndex int `json:"rank_index"` // ۰..۴
|
||||||
@@ -66,8 +67,8 @@ func (s *Service) GetProfile(ctx context.Context, userID int64) (*Profile, error
|
|||||||
var p Profile
|
var p Profile
|
||||||
var vipUntil sql.NullString
|
var vipUntil sql.NullString
|
||||||
err := s.db.QueryRowContext(ctx,
|
err := s.db.QueryRowContext(ctx,
|
||||||
`SELECT coins, tickets, xp, trophies, vip_until, selected_card, rank_points FROM users WHERE id = ?`, userID).
|
`SELECT coins, tickets, xp, trophies, vip_until, selected_card, selected_carpet, rank_points FROM users WHERE id = ?`, userID).
|
||||||
Scan(&p.Coins, &p.Tickets, &p.XP, &p.Trophies, &vipUntil, &p.SelectedCard, &p.RankPoints)
|
Scan(&p.Coins, &p.Tickets, &p.XP, &p.Trophies, &vipUntil, &p.SelectedCard, &p.SelectedCarpet, &p.RankPoints)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,73 @@ func (h *Handler) BuyCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Carpets — GET /api/carpets (فرشها + مالکیت + فرشِ انتخابی)
|
||||||
|
func (h *Handler) Carpets(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := uid(r)
|
||||||
|
if !ok {
|
||||||
|
httpx.Error(w, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
carpets, err := h.svc.Carpets(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
httpx.Error(w, http.StatusInternalServerError, "server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selected := "classic"
|
||||||
|
if p, _ := h.svc.GetProfile(r.Context(), id); p != nil && p.SelectedCarpet != "" {
|
||||||
|
selected = p.SelectedCarpet
|
||||||
|
}
|
||||||
|
httpx.JSON(w, http.StatusOK, map[string]any{
|
||||||
|
"carpets": carpets,
|
||||||
|
"selected": selected,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuyCarpet — POST /api/shop/buy-carpet
|
||||||
|
func (h *Handler) BuyCarpet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.carpetAction(w, r, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectCarpet — POST /api/shop/select-carpet
|
||||||
|
func (h *Handler) SelectCarpet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.carpetAction(w, r, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) carpetAction(w http.ResponseWriter, r *http.Request, sel bool) {
|
||||||
|
id, ok := uid(r)
|
||||||
|
if !ok {
|
||||||
|
httpx.Error(w, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
CarpetID string `json:"carpet_id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
httpx.Error(w, http.StatusBadRequest, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
if sel {
|
||||||
|
err = h.svc.SelectCarpet(r.Context(), id, req.CarpetID)
|
||||||
|
} else {
|
||||||
|
err = h.svc.BuyCarpet(r.Context(), id, req.CarpetID)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
httpx.JSON(w, http.StatusOK, map[string]string{"message": "ok"})
|
||||||
|
case errors.Is(err, ErrNotFound):
|
||||||
|
httpx.Error(w, http.StatusNotFound, "carpet not found")
|
||||||
|
case errors.Is(err, ErrAlreadyOwned):
|
||||||
|
httpx.Error(w, http.StatusConflict, "already owned")
|
||||||
|
case errors.Is(err, ErrNotOwned):
|
||||||
|
httpx.Error(w, http.StatusForbidden, "carpet not owned")
|
||||||
|
case errors.Is(err, ErrInsufficient):
|
||||||
|
httpx.Error(w, http.StatusPaymentRequired, "insufficient coins")
|
||||||
|
default:
|
||||||
|
httpx.Error(w, http.StatusInternalServerError, "server error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ChatPacks — GET /api/chat-packs (بستههای پیام/شکلک + مالکیتِ کاربر)
|
// ChatPacks — GET /api/chat-packs (بستههای پیام/شکلک + مالکیتِ کاربر)
|
||||||
func (h *Handler) ChatPacks(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ChatPacks(w http.ResponseWriter, r *http.Request) {
|
||||||
id, ok := uid(r)
|
id, ok := uid(r)
|
||||||
|
|||||||
|
After Width: | Height: | Size: 499 KiB |
|
After Width: | Height: | Size: 592 KiB |
|
After Width: | Height: | Size: 507 KiB |
|
After Width: | Height: | Size: 556 KiB |
|
After Width: | Height: | Size: 618 KiB |
|
After Width: | Height: | Size: 660 KiB |
|
After Width: | Height: | Size: 525 KiB |
@@ -17,6 +17,9 @@ import (
|
|||||||
//go:embed cards
|
//go:embed cards
|
||||||
var cardsFS embed.FS
|
var cardsFS embed.FS
|
||||||
|
|
||||||
|
//go:embed carpets
|
||||||
|
var carpetsFS embed.FS
|
||||||
|
|
||||||
// CardsHandler یک http.Handler برای سرویسدهیِ فایلهای کارت زیر /cards/ است.
|
// CardsHandler یک http.Handler برای سرویسدهیِ فایلهای کارت زیر /cards/ است.
|
||||||
// diskDir پوشهی اسکینهای آپلودی است (میتواند خالی/ناموجود باشد).
|
// diskDir پوشهی اسکینهای آپلودی است (میتواند خالی/ناموجود باشد).
|
||||||
// CORS باز است تا نسخهٔ وب (Flutter web / canvaskit) هم بتواند تصاویر را بکشد.
|
// CORS باز است تا نسخهٔ وب (Flutter web / canvaskit) هم بتواند تصاویر را بکشد.
|
||||||
@@ -45,3 +48,29 @@ func CardsHandler(diskDir string) http.Handler {
|
|||||||
embedded.ServeHTTP(w, r)
|
embedded.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CarpetsHandler تصاویرِ فرش را سرو میکند: GET /carpets/<id>.jpg
|
||||||
|
// ابتدا از پوشهی دیسکی (آپلودِ ادمین)، سپس از فرشهای embedشدهی پیشفرض.
|
||||||
|
// اگر هیچکدام نبود ۴۰۴ (اپ به میزِ ترسیمیِ classic برمیگردد).
|
||||||
|
func CarpetsHandler(diskDir string) http.Handler {
|
||||||
|
sub, err := fs.Sub(carpetsFS, "carpets")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
embedded := http.StripPrefix("/carpets/", http.FileServer(http.FS(sub)))
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=604800")
|
||||||
|
// تلاشِ اول: پوشهی دیسکی (آپلودِ ادمین).
|
||||||
|
if diskDir != "" {
|
||||||
|
rel := filepath.Clean("/" + strings.TrimPrefix(r.URL.Path, "/carpets/"))
|
||||||
|
full := filepath.Join(diskDir, rel)
|
||||||
|
if st, err := os.Stat(full); err == nil && !st.IsDir() {
|
||||||
|
http.ServeFile(w, r, full)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// تلاشِ دوم: فرشهای embedشده.
|
||||||
|
embedded.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- فرشهای ایرانی که بهجای میزِ بازی استفاده میشوند (تصویرِ هر فرش از سرور).
|
||||||
|
CREATE TABLE IF NOT EXISTS carpets (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
price_coins INTEGER NOT NULL DEFAULT 0, -- ۰ یعنی رایگان (مگر vip)
|
||||||
|
vip INTEGER NOT NULL DEFAULT 0, -- ۱ یعنی برای VIP رایگان
|
||||||
|
sort INTEGER NOT NULL DEFAULT 0,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_carpets (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
carpet_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, carpet_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- فرشِ انتخابیِ کاربر؛ 'classic' یعنی میزِ پیشفرضِ ترسیمی (بدون تصویر).
|
||||||
|
ALTER TABLE users ADD COLUMN selected_carpet TEXT NOT NULL DEFAULT 'classic';
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO carpets (id, title, price_coins, vip, sort) VALUES
|
||||||
|
('classic', 'میزِ کلاسیک', 0, 0, 1),
|
||||||
|
('kashan', 'فرشِ کاشان', 8000, 0, 2),
|
||||||
|
('tabriz', 'فرشِ تبریز', 15000, 0, 3),
|
||||||
|
('royal', 'فرشِ سلطنتی', 0, 1, 4);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- فرشهای بیشتر (تصاویرِ embedشده در سرور). kashan/tabriz/royal در 009 هستند.
|
||||||
|
INSERT OR IGNORE INTO carpets (id, title, price_coins, vip, sort) VALUES
|
||||||
|
('qom', 'فرشِ قم', 18000, 0, 5),
|
||||||
|
('nain', 'فرشِ نائین', 12000, 0, 6),
|
||||||
|
('isfahan', 'فرشِ اصفهان', 25000, 0, 7),
|
||||||
|
('shiraz', 'فرشِ شیراز', 10000, 0, 8);
|
||||||