diff --git a/cmd/server/main.go b/cmd/server/main.go index 23c43bb..aa5a952 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -106,6 +106,8 @@ func main() { r.Get("/shop", ecoH.Shop) r.Post("/shop/buy-card", ecoH.BuyCard) r.Post("/shop/select-card", ecoH.SelectCard) + r.Get("/chat-packs", ecoH.ChatPacks) + r.Post("/shop/buy-chat-pack", ecoH.BuyChatPack) r.Post("/shop/purchase", ecoH.Purchase) r.Post("/rewards/daily", ecoH.Daily) r.Post("/rewards/ad", ecoH.AdReward) diff --git a/internal/admin/admin.go b/internal/admin/admin.go index 4ca6eb7..8d25fac 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -51,6 +51,11 @@ func (h *Handler) Routes(user, pass string) http.Handler { r.Post("/users/coins", h.adjustCoins) r.Post("/users/grant", h.grantToUser) r.Post("/season/reset", h.resetSeason) + r.Get("/chat", h.chatAdmin) + r.Post("/chat/pack/add", h.chatPackAdd) + r.Post("/chat/pack/delete", h.chatPackDelete) + r.Post("/chat/message/add", h.chatMessageAdd) + r.Post("/chat/message/delete", h.chatMessageDelete) return r } @@ -438,6 +443,103 @@ func (h *Handler) grantToUser(w http.ResponseWriter, r *http.Request) { } // rows یک کوئری را به []map[string]any تبدیل می‌کند (برای رندرِ عمومیِ جدول‌ها). +// --- مدیریتِ بسته‌های چت و پیام‌ها --- + +func (h *Handler) chatAdmin(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + packs := h.rows(ctx, + `SELECT id,title,kind,price_coins,vip,sort,enabled FROM chat_packs ORDER BY sort`, + "id", "title", "kind", "price_coins", "vip", "sort", "enabled") + // پیام‌های هر بسته را زیرِ همان بسته قرار می‌دهیم. + for _, p := range packs { + id, _ := p["id"].(string) + p["messages"] = h.messagesOf(ctx, id) + } + h.render(w, "chat.html", map[string]any{ + "Nav": "chat", + "Packs": packs, + "Saved": r.URL.Query().Get("saved") == "1", + }) +} + +func (h *Handler) messagesOf(ctx context.Context, packID string) []map[string]any { + rs, err := h.db.QueryContext(ctx, + `SELECT id,body,sort FROM chat_messages WHERE pack_id=? ORDER BY sort,id`, packID) + if err != nil { + return nil + } + defer rs.Close() + var out []map[string]any + for rs.Next() { + var id, sort int + var body string + if err := rs.Scan(&id, &body, &sort); err != nil { + return out + } + out = append(out, map[string]any{"id": id, "body": body, "sort": sort}) + } + return out +} + +func (h *Handler) chatPackAdd(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + id := r.FormValue("id") + if id == "" { + http.Error(w, "id required", 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 + } + kind := r.FormValue("kind") + if kind != "emoji" { + kind = "text" + } + _, err := h.db.ExecContext(r.Context(), + `INSERT OR REPLACE INTO chat_packs (id,title,kind,price_coins,vip,sort,enabled) VALUES (?,?,?,?,?,?,1)`, + id, r.FormValue("title"), kind, num("price_coins"), vip, num("sort")) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/admin/chat?saved=1", http.StatusSeeOther) +} + +func (h *Handler) chatPackDelete(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + id := r.FormValue("id") + _, _ = h.db.ExecContext(r.Context(), `DELETE FROM chat_messages WHERE pack_id=?`, id) + _, _ = h.db.ExecContext(r.Context(), `DELETE FROM chat_packs WHERE id=?`, id) + http.Redirect(w, r, "/admin/chat?saved=1", http.StatusSeeOther) +} + +func (h *Handler) chatMessageAdd(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + pack := r.FormValue("pack_id") + body := r.FormValue("body") + if pack == "" || body == "" { + http.Redirect(w, r, "/admin/chat", http.StatusSeeOther) + return + } + sort, _ := strconv.Atoi(r.FormValue("sort")) + _, err := h.db.ExecContext(r.Context(), + `INSERT INTO chat_messages (pack_id,body,sort) VALUES (?,?,?)`, pack, body, sort) + if 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) { + _ = r.ParseForm() + id, _ := strconv.Atoi(r.FormValue("id")) + _, _ = h.db.ExecContext(r.Context(), `DELETE FROM chat_messages WHERE id=?`, id) + http.Redirect(w, r, "/admin/chat?saved=1", http.StatusSeeOther) +} + func (h *Handler) rows(ctx context.Context, query string, cols ...string) []map[string]any { rs, err := h.db.QueryContext(ctx, query) if err != nil { diff --git a/internal/admin/templates/chat.html b/internal/admin/templates/chat.html new file mode 100644 index 0000000..ab36c4a --- /dev/null +++ b/internal/admin/templates/chat.html @@ -0,0 +1,71 @@ + + +{{template "head" .}} + +{{template "nav" .}} +
+

بسته‌های چت

+ {{if .Saved}}
انجام شد ✓
{{end}} + +

+ هر بسته شاملِ چند پیامِ آماده یا شکلک است. بسته با قیمتِ ۰ و بدونِ VIP برای + همه رایگان است؛ تیکِ VIP یعنی برای کاربرانِ VIP رایگان (و برای بقیه با قیمتِ سکه). +

+ +

افزودن / ویرایش بسته

+ + + {{range .Packs}} + + + + + + + + + + {{end}} + + + + + + + + + +
شناسهعنواننوعقیمت (سکه)VIP رایگانترتیب
{{.id}} + + + + +
+ + {{range .Packs}} +

پیام‌های «{{.title}}» ({{.id}})

+ + + {{range .messages}} + + + + + + {{end}} + + + + + +
متن / شکلکترتیب
{{.body}}{{.sort}} + + +
+ {{end}} +
+ + diff --git a/internal/admin/templates/layout.html b/internal/admin/templates/layout.html index b335158..53c97d5 100644 --- a/internal/admin/templates/layout.html +++ b/internal/admin/templates/layout.html @@ -33,6 +33,7 @@ {{end}} diff --git a/internal/economy/chat.go b/internal/economy/chat.go new file mode 100644 index 0000000..9fc6b3a --- /dev/null +++ b/internal/economy/chat.go @@ -0,0 +1,129 @@ +package economy + +import "context" + +// ChatPack یک بسته‌ی پیام/شکلکِ چت. Owned یعنی کاربر می‌تواند از آن استفاده کند +// (رایگان، یا VIP برای بسته‌ی vip، یا خریداری‌شده). +type ChatPack struct { + ID string `json:"id"` + Title string `json:"title"` + Kind string `json:"kind"` // text | emoji + PriceCoins int64 `json:"price_coins"` + VIP bool `json:"vip"` // برای VIP رایگان است + Owned bool `json:"owned"` + Messages []string `json:"messages"` +} + +// ChatPacks بسته‌های فعال را همراه پیام‌ها و وضعیتِ مالکیتِ کاربر برمی‌گرداند. +func (s *Service) ChatPacks(ctx context.Context, userID int64) ([]ChatPack, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, title, kind, price_coins, vip FROM chat_packs WHERE enabled = 1 ORDER BY sort`) + if err != nil { + return nil, err + } + defer rows.Close() + + var packs []ChatPack + for rows.Next() { + var p ChatPack + var vip int + if err := rows.Scan(&p.ID, &p.Title, &p.Kind, &p.PriceCoins, &vip); err != nil { + return nil, err + } + p.VIP = vip == 1 + packs = append(packs, p) + } + if err := rows.Err(); err != nil { + return nil, err + } + + owned, err := s.ownedChatPacks(ctx, userID) + if err != nil { + return nil, err + } + isVIP := s.isVIP(ctx, userID) + + for i := range packs { + p := &packs[i] + // رایگان (بدون قیمت و غیرِ vip)، یا VIP برای بسته‌ی vip، یا خریداری‌شده. + p.Owned = (p.PriceCoins == 0 && !p.VIP) || (p.VIP && isVIP) || owned[p.ID] + msgs, err := s.packMessages(ctx, p.ID) + if err != nil { + return nil, err + } + p.Messages = msgs + } + return packs, nil +} + +func (s *Service) packMessages(ctx context.Context, packID string) ([]string, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT body FROM chat_messages WHERE pack_id = ? ORDER BY sort, id`, packID) + if err != nil { + return nil, err + } + defer rows.Close() + msgs := []string{} + for rows.Next() { + var b string + if err := rows.Scan(&b); err != nil { + return nil, err + } + msgs = append(msgs, b) + } + return msgs, rows.Err() +} + +func (s *Service) ownedChatPacks(ctx context.Context, userID int64) (map[string]bool, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT pack_id FROM user_chat_packs 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() +} + +// BuyChatPack یک بسته‌ی چت را با سکه باز می‌کند. بسته‌های رایگان/قبلاً‌مالک خطا +// می‌دهند؛ بسته‌ی VIP برای کاربرِ VIP خودبه‌خود در دسترس است (نیازی به خرید نیست). +func (s *Service) BuyChatPack(ctx context.Context, userID int64, packID string) error { + var price int64 + var vip, enabled int + err := s.db.QueryRowContext(ctx, + `SELECT price_coins, vip, enabled FROM chat_packs WHERE id = ?`, packID). + 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_chat_packs WHERE user_id = ? AND pack_id = ?`, userID, packID).Scan(&exists) + if exists == 1 { + return ErrAlreadyOwned + } + if price > 0 { + if err := adjustTx(ctx, tx, userID, CurrencyCoin, -price, "buy_chat_pack", packID); err != nil { + return err + } + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO user_chat_packs (user_id, pack_id) VALUES (?, ?)`, userID, packID); err != nil { + return err + } + return tx.Commit() +} diff --git a/internal/economy/handler.go b/internal/economy/handler.go index 07f25ac..82ad405 100644 --- a/internal/economy/handler.go +++ b/internal/economy/handler.go @@ -87,6 +87,49 @@ func (h *Handler) BuyCard(w http.ResponseWriter, r *http.Request) { } } +// ChatPacks — GET /api/chat-packs (بسته‌های پیام/شکلک + مالکیتِ کاربر) +func (h *Handler) ChatPacks(w http.ResponseWriter, r *http.Request) { + id, ok := uid(r) + if !ok { + httpx.Error(w, http.StatusUnauthorized, "unauthorized") + return + } + packs, err := h.svc.ChatPacks(r.Context(), id) + if err != nil { + httpx.Error(w, http.StatusInternalServerError, "server error") + return + } + httpx.JSON(w, http.StatusOK, map[string]any{"packs": packs}) +} + +// BuyChatPack — POST /api/shop/buy-chat-pack +func (h *Handler) BuyChatPack(w http.ResponseWriter, r *http.Request) { + id, ok := uid(r) + if !ok { + httpx.Error(w, http.StatusUnauthorized, "unauthorized") + return + } + var req struct { + PackID string `json:"pack_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpx.Error(w, http.StatusBadRequest, "invalid body") + return + } + switch err := h.svc.BuyChatPack(r.Context(), id, req.PackID); { + case err == nil: + httpx.JSON(w, http.StatusOK, map[string]string{"message": "ok"}) + case errors.Is(err, ErrNotFound): + httpx.Error(w, http.StatusNotFound, "pack not found") + case errors.Is(err, ErrAlreadyOwned): + httpx.Error(w, http.StatusConflict, "already owned") + case errors.Is(err, ErrInsufficient): + httpx.Error(w, http.StatusPaymentRequired, "insufficient coins") + default: + httpx.Error(w, http.StatusInternalServerError, "server error") + } +} + // SelectCard — POST /api/shop/select-card func (h *Handler) SelectCard(w http.ResponseWriter, r *http.Request) { id, ok := uid(r) diff --git a/internal/store/migrations/008_chat.sql b/internal/store/migrations/008_chat.sql new file mode 100644 index 0000000..a7a612b --- /dev/null +++ b/internal/store/migrations/008_chat.sql @@ -0,0 +1,58 @@ +-- بسته‌های چت (پیام‌های آماده و شکلک‌ها). برخی رایگان، برخی با سکه یا ویژه‌ی VIP. +CREATE TABLE IF NOT EXISTS chat_packs ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'text', -- text | emoji + 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 chat_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pack_id TEXT NOT NULL, + body TEXT NOT NULL, -- متنِ پیام یا کاراکترِ شکلک + sort INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_chat_messages_pack ON chat_messages(pack_id); + +-- مالکیتِ بسته‌های خریداری‌شده توسط کاربر. +CREATE TABLE IF NOT EXISTS user_chat_packs ( + user_id INTEGER NOT NULL, + pack_id TEXT NOT NULL, + PRIMARY KEY (user_id, pack_id) +); + +INSERT OR IGNORE INTO chat_packs (id, title, kind, price_coins, vip, sort) VALUES + ('general', 'عمومی', 'text', 0, 0, 1), + ('friendly', 'دوستانه', 'text', 2500, 0, 2), + ('kolkol', 'کل کل', 'text', 5500, 0, 3), + ('hakem', 'حکم بازها', 'text', 6500, 0, 4), + ('emoji', 'شکلک', 'emoji', 1000, 1, 5); + +INSERT OR IGNORE INTO chat_messages (pack_id, body, sort) VALUES + -- عمومی (رایگان) + ('general','سلام',1),('general','ساکت',2),('general','بعد بازی بیا اتاق چت!',3), + ('general','عجب شانسی!',4),('general','با شما نبودم',5),('general','رحم کن',6), + ('general','شرمنده',7),('general','حواسم نبود',8),('general','ای بابا',9), + ('general','آخیش',10),('general','نت ضعیفه',11),('general','بیا دیگه',12), + -- دوستانه + ('friendly','به به بروبچ با صفا',1),('friendly','دم شما گرم',2),('friendly','خیلی مخلصیم',3), + ('friendly','به مولا اگه بذارم',4),('friendly','آقایی',5),('friendly','با خاک پاتیم',6), + ('friendly','فدایی داری',7),('friendly','حال و احوال؟',8),('friendly','سلاطین',9), + ('friendly','آبجی خودمی',10),('friendly','شماره بدی زنگ میزنم',11), + -- کل کل + ('kolkol','اینکاره نیستی',1),('kolkol','ها ها ها ها',2),('kolkol','خخخخخ...',3), + ('kolkol','هنوز زوده برات',4),('kolkol','حالتو میگیرم',5),('kolkol','شانستو ببین',6), + ('kolkol','شاخ نسو',7),('kolkol','بی بی بودی!',8),('kolkol','برو کنار بذار باد بیاد',9), + ('kolkol','ناخونات نشکنه جیگر',10),('kolkol','چرا میخندی؟',11),('kolkol','یاد گرفتی؟',12), + -- حکم بازها + ('hakem','کارت تمومه',1),('hakem','هیس بابا',2),('hakem','کریم بخدا مسلمون نیستی',3), + ('hakem','حاکم فقط خودم',4),('hakem','کت نشین! با جواب میخوام',5),('hakem','دست خوبم نداریم',6), + ('hakem','کمه!',7),('hakem','حواست کجاست؟',8),('hakem','شیرزن',9), + ('hakem','خیلی عقبید',10),('hakem','برادرمی',11), + -- شکلک (emoji) + ('emoji','😎',1),('emoji','😉',2),('emoji','😌',3),('emoji','😄',4),('emoji','😆',5), + ('emoji','😡',6),('emoji','😍',7),('emoji','🙄',8),('emoji','😭',9),('emoji','👋',10), + ('emoji','🤝',11),('emoji','🤔',12),('emoji','😴',13),('emoji','🤑',14),('emoji','😵',15),('emoji','🥳',16);