feat: add chat for backend

This commit is contained in:
2026-06-25 23:34:41 +03:30
parent 9a86d2bd4d
commit d290ba86bb
7 changed files with 406 additions and 0 deletions
+2
View File
@@ -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)
+102
View File
@@ -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 {
+71
View File
@@ -0,0 +1,71 @@
<!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">
هر بسته شاملِ چند پیامِ آماده یا شکلک است. بسته با قیمتِ ۰ و بدونِ VIP برای
همه رایگان است؛ تیکِ VIP یعنی برای کاربرانِ VIP رایگان (و برای بقیه با قیمتِ سکه).
</p>
<h2>افزودن / ویرایش بسته</h2>
<table>
<tr><th>شناسه</th><th>عنوان</th><th>نوع</th><th>قیمت (سکه)</th><th>VIP رایگان</th><th>ترتیب</th><th></th></tr>
{{range .Packs}}
<tr><form method="post" action="/admin/chat/pack/add">
<td>{{.id}}<input type="hidden" name="id" value="{{.id}}"></td>
<td><input name="title" value="{{.title}}"></td>
<td>
<select name="kind">
<option value="text" {{if eq .kind "text"}}selected{{end}}>متن</option>
<option value="emoji" {{if eq .kind "emoji"}}selected{{end}}>شکلک</option>
</select>
</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>
<button>ذخیره</button>
<button formaction="/admin/chat/pack/delete" formnovalidate class="del">حذف</button>
</td>
</form></tr>
{{end}}
<tr class="addrow"><form method="post" action="/admin/chat/pack/add">
<td><input name="id" placeholder="شناسه" required></td>
<td><input name="title" placeholder="عنوان"></td>
<td><select name="kind"><option value="text">متن</option><option value="emoji">شکلک</option></select></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><button>افزودن بسته</button></td>
</form></tr>
</table>
{{range .Packs}}
<h2>پیام‌های «{{.title}}» <small style="color:#9aa">({{.id}})</small></h2>
<table>
<tr><th>متن / شکلک</th><th>ترتیب</th><th></th></tr>
{{range .messages}}
<tr><form method="post" action="/admin/chat/message/delete">
<td>{{.body}}</td>
<td>{{.sort}}</td>
<td>
<input type="hidden" name="id" value="{{.id}}">
<button class="del">حذف</button>
</td>
</form></tr>
{{end}}
<tr class="addrow"><form method="post" action="/admin/chat/message/add">
<td><input type="hidden" name="pack_id" value="{{.id}}"><input name="body" placeholder="متن یا شکلک" required style="width:100%"></td>
<td><input name="sort" value="0" style="width:50px"></td>
<td><button>افزودن پیام</button></td>
</form></tr>
</table>
{{end}}
</div>
</body>
</html>
+1
View File
@@ -33,6 +33,7 @@
<nav>
<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/chat" class="{{if eq .Nav "chat"}}active{{end}}">چت</a>
<a href="/admin/users" class="{{if eq .Nav "users"}}active{{end}}">کاربران</a>
</nav>
{{end}}
+129
View File
@@ -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()
}
+43
View File
@@ -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)
+58
View File
@@ -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);