feat: add characters

This commit is contained in:
2026-07-10 00:56:03 +03:30
parent da6df0328f
commit fe0b63083c
19 changed files with 635 additions and 92 deletions
+128 -61
View File
@@ -39,6 +39,11 @@ func (h *Handler) APIRoutes() http.Handler {
r.Post("/frames", h.apiFrameUpsert)
r.Delete("/frames", h.apiFrameDelete)
r.Get("/avatars", h.apiAvatars)
r.Post("/avatars", h.apiAvatarUpsert)
r.Delete("/avatars", h.apiAvatarDelete)
r.Post("/avatars/{id}/upload", h.avatarUpload) // multipart (تصویرِ png)
r.Get("/rank-tiers", h.apiRankTiers)
r.Post("/rank-tiers", h.apiRankTierUpsert)
@@ -201,14 +206,14 @@ func (h *Handler) apiUsers(w http.ResponseWriter, r *http.Request) {
func (h *Handler) apiUserCoins(w http.ResponseWriter, r *http.Request) {
var b struct {
UserID int64 `json:"user_id"`
Amount int64 `json:"amount"`
UserID flexInt `json:"user_id"`
Amount flexInt `json:"amount"`
}
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || b.UserID == 0 {
httpx.Error(w, http.StatusBadRequest, "invalid body")
httpx.Error(w, http.StatusBadRequest, "بدنه‌ی نامعتبر")
return
}
if err := h.eco.Adjust(r.Context(), b.UserID, economy.CurrencyCoin, b.Amount, "admin_adjust", "panel"); err != nil {
if err := h.eco.Adjust(r.Context(), b.UserID.i(), economy.CurrencyCoin, b.Amount.i(), "admin_adjust", "panel"); err != nil {
httpx.Error(w, http.StatusBadRequest, err.Error())
return
}
@@ -217,26 +222,26 @@ func (h *Handler) apiUserCoins(w http.ResponseWriter, r *http.Request) {
func (h *Handler) apiUserGrant(w http.ResponseWriter, r *http.Request) {
var b struct {
UserID int64 `json:"user_id"`
Kind string `json:"kind"` // coin | ticket | vip
Amount int64 `json:"amount"`
VIPDays int `json:"vip_days"`
UserID flexInt `json:"user_id"`
Kind string `json:"kind"` // coin | ticket | vip
Amount flexInt `json:"amount"`
VIPDays flexInt `json:"vip_days"`
}
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || b.UserID == 0 {
httpx.Error(w, http.StatusBadRequest, "invalid body")
httpx.Error(w, http.StatusBadRequest, "بدنه‌ی نامعتبر")
return
}
ctx := r.Context()
var err error
switch b.Kind {
case "coin":
err = h.eco.Adjust(ctx, b.UserID, economy.CurrencyCoin, b.Amount, "admin_grant", "panel")
err = h.eco.Adjust(ctx, b.UserID.i(), economy.CurrencyCoin, b.Amount.i(), "admin_grant", "panel")
case "ticket":
err = h.eco.Adjust(ctx, b.UserID, economy.CurrencyTicket, b.Amount, "admin_grant", "panel")
err = h.eco.Adjust(ctx, b.UserID.i(), economy.CurrencyTicket, b.Amount.i(), "admin_grant", "panel")
case "vip":
_, err = h.db.ExecContext(ctx,
`UPDATE users SET vip_until = datetime('now', ?) WHERE id = ?`,
fmt.Sprintf("+%d days", b.VIPDays), b.UserID)
fmt.Sprintf("+%d days", b.VIPDays.i()), b.UserID.i())
default:
httpx.Error(w, http.StatusBadRequest, "unknown kind")
return
@@ -260,15 +265,22 @@ func (h *Handler) apiCarpets(w http.ResponseWriter, r *http.Request) {
func (h *Handler) apiCarpetUpsert(w http.ResponseWriter, r *http.Request) {
var b struct {
ID string `json:"id"`
Title string `json:"title"`
PriceCoins int64 `json:"price_coins"`
VIP bool `json:"vip"`
Sort int `json:"sort"`
ID string `json:"id"`
Title string `json:"title"`
PriceCoins flexInt `json:"price_coins"`
VIP flexBool `json:"vip"`
Sort flexInt `json:"sort"`
}
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || strings.TrimSpace(b.ID) == "" ||
strings.ContainsAny(b.ID, `/\.`) {
httpx.Error(w, http.StatusBadRequest, "invalid body")
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
httpx.Error(w, http.StatusBadRequest, "بدنه‌ی نامعتبر")
return
}
if strings.TrimSpace(b.ID) == "" {
httpx.Error(w, http.StatusBadRequest, "شناسه (id) لازم است")
return
}
if strings.ContainsAny(b.ID, `/\.`) {
httpx.Error(w, http.StatusBadRequest, "شناسه نباید شاملِ نقطه، / یا \\ باشد")
return
}
vip := 0
@@ -277,7 +289,7 @@ func (h *Handler) apiCarpetUpsert(w http.ResponseWriter, r *http.Request) {
}
if _, err := h.db.ExecContext(r.Context(),
`INSERT OR REPLACE INTO carpets (id,title,price_coins,vip,sort,enabled) VALUES (?,?,?,?,?,1)`,
b.ID, b.Title, b.PriceCoins, vip, b.Sort); err != nil {
b.ID, b.Title, b.PriceCoins.i(), vip, b.Sort.i()); err != nil {
httpx.Error(w, http.StatusInternalServerError, err.Error())
return
}
@@ -289,6 +301,57 @@ func (h *Handler) apiCarpetDelete(w http.ResponseWriter, r *http.Request) {
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
}
// --- شخصیت‌های آواتار ---
func (h *Handler) apiAvatars(w http.ResponseWriter, r *http.Request) {
httpx.JSON(w, http.StatusOK, map[string]any{
"avatars": h.rows(r.Context(),
`SELECT id,title,price_coins,vip,sort,enabled,img_ext FROM avatars ORDER BY sort`,
"id", "title", "price_coins", "vip", "sort", "enabled", "img_ext"),
})
}
func (h *Handler) apiAvatarUpsert(w http.ResponseWriter, r *http.Request) {
var b struct {
ID string `json:"id"`
Title string `json:"title"`
PriceCoins flexInt `json:"price_coins"`
VIP flexBool `json:"vip"`
Sort flexInt `json:"sort"`
}
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
httpx.Error(w, http.StatusBadRequest, "بدنه‌ی نامعتبر")
return
}
if strings.TrimSpace(b.ID) == "" {
httpx.Error(w, http.StatusBadRequest, "شناسه (id) لازم است")
return
}
if strings.ContainsAny(b.ID, `/\.`) {
httpx.Error(w, http.StatusBadRequest, "شناسه نباید شاملِ نقطه، / یا \\ باشد")
return
}
vip := 0
if b.VIP {
vip = 1
}
// ON CONFLICT به‌جای REPLACE تا img_ext (نوعِ فایلِ آپلودشده) هنگامِ ویرایش پاک نشود.
if _, err := h.db.ExecContext(r.Context(),
`INSERT INTO avatars (id,title,price_coins,vip,sort,enabled) VALUES (?,?,?,?,?,1)
ON CONFLICT(id) DO UPDATE SET title=excluded.title, price_coins=excluded.price_coins,
vip=excluded.vip, sort=excluded.sort, enabled=1`,
b.ID, b.Title, b.PriceCoins.i(), vip, b.Sort.i()); err != nil {
httpx.Error(w, http.StatusInternalServerError, err.Error())
return
}
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (h *Handler) apiAvatarDelete(w http.ResponseWriter, r *http.Request) {
_, _ = h.db.ExecContext(r.Context(), `DELETE FROM avatars WHERE id=?`, r.URL.Query().Get("id"))
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
}
// --- قاب‌های آواتار ---
func (h *Handler) apiFrames(w http.ResponseWriter, r *http.Request) {
@@ -301,17 +364,17 @@ func (h *Handler) apiFrames(w http.ResponseWriter, r *http.Request) {
func (h *Handler) apiFrameUpsert(w http.ResponseWriter, r *http.Request) {
var b struct {
ID string `json:"id"`
Title string `json:"title"`
PriceCoins int64 `json:"price_coins"`
VIP bool `json:"vip"`
C1 string `json:"c1"`
C2 string `json:"c2"`
Sort int `json:"sort"`
Enabled *bool `json:"enabled"`
ID string `json:"id"`
Title string `json:"title"`
PriceCoins flexInt `json:"price_coins"`
VIP flexBool `json:"vip"`
C1 string `json:"c1"`
C2 string `json:"c2"`
Sort flexInt `json:"sort"`
Enabled *flexBool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || strings.TrimSpace(b.ID) == "" {
httpx.Error(w, http.StatusBadRequest, "invalid body")
httpx.Error(w, http.StatusBadRequest, "شناسه (id) لازم است")
return
}
vip := 0
@@ -326,7 +389,7 @@ func (h *Handler) apiFrameUpsert(w http.ResponseWriter, r *http.Request) {
clean := func(s string) string { return strings.TrimPrefix(strings.TrimSpace(s), "#") }
if _, err := h.db.ExecContext(r.Context(),
`INSERT OR REPLACE INTO avatar_frames (id,title,price_coins,vip,c1,c2,sort,enabled) VALUES (?,?,?,?,?,?,?,?)`,
b.ID, b.Title, b.PriceCoins, vip, clean(b.C1), clean(b.C2), b.Sort, enabled); err != nil {
b.ID, b.Title, b.PriceCoins.i(), vip, clean(b.C1), clean(b.C2), b.Sort.i(), enabled); err != nil {
httpx.Error(w, http.StatusInternalServerError, err.Error())
return
}
@@ -350,20 +413,20 @@ func (h *Handler) apiRankTiers(w http.ResponseWriter, r *http.Request) {
func (h *Handler) apiRankTierUpsert(w http.ResponseWriter, r *http.Request) {
var b struct {
ID string `json:"id"`
Label string `json:"label"`
C1 string `json:"c1"`
C2 string `json:"c2"`
Sort int `json:"sort"`
ID string `json:"id"`
Label string `json:"label"`
C1 string `json:"c1"`
C2 string `json:"c2"`
Sort flexInt `json:"sort"`
}
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || strings.TrimSpace(b.ID) == "" {
httpx.Error(w, http.StatusBadRequest, "invalid body")
httpx.Error(w, http.StatusBadRequest, "شناسه (id) لازم است")
return
}
clean := func(s string) string { return strings.TrimPrefix(strings.TrimSpace(s), "#") }
if _, err := h.db.ExecContext(r.Context(),
`INSERT OR REPLACE INTO rank_tiers (id,label,c1,c2,sort) VALUES (?,?,?,?,?)`,
b.ID, b.Label, clean(b.C1), clean(b.C2), b.Sort); err != nil {
b.ID, b.Label, clean(b.C1), clean(b.C2), b.Sort.i()); err != nil {
httpx.Error(w, http.StatusInternalServerError, err.Error())
return
}
@@ -386,15 +449,15 @@ func (h *Handler) apiChat(w http.ResponseWriter, r *http.Request) {
func (h *Handler) apiChatPackUpsert(w http.ResponseWriter, r *http.Request) {
var b struct {
ID string `json:"id"`
Title string `json:"title"`
Kind string `json:"kind"`
PriceCoins int64 `json:"price_coins"`
VIP bool `json:"vip"`
Sort int `json:"sort"`
ID string `json:"id"`
Title string `json:"title"`
Kind string `json:"kind"`
PriceCoins flexInt `json:"price_coins"`
VIP flexBool `json:"vip"`
Sort flexInt `json:"sort"`
}
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || strings.TrimSpace(b.ID) == "" {
httpx.Error(w, http.StatusBadRequest, "invalid body")
httpx.Error(w, http.StatusBadRequest, "شناسه (id) لازم است")
return
}
if b.Kind != "emoji" {
@@ -406,7 +469,7 @@ func (h *Handler) apiChatPackUpsert(w http.ResponseWriter, r *http.Request) {
}
if _, err := h.db.ExecContext(r.Context(),
`INSERT OR REPLACE INTO chat_packs (id,title,kind,price_coins,vip,sort,enabled) VALUES (?,?,?,?,?,?,1)`,
b.ID, b.Title, b.Kind, b.PriceCoins, vip, b.Sort); err != nil {
b.ID, b.Title, b.Kind, b.PriceCoins.i(), vip, b.Sort.i()); err != nil {
httpx.Error(w, http.StatusInternalServerError, err.Error())
return
}
@@ -422,16 +485,16 @@ func (h *Handler) apiChatPackDelete(w http.ResponseWriter, r *http.Request) {
func (h *Handler) apiChatMessageAdd(w http.ResponseWriter, r *http.Request) {
var b struct {
PackID string `json:"pack_id"`
Body string `json:"body"`
Sort int `json:"sort"`
PackID string `json:"pack_id"`
Body string `json:"body"`
Sort flexInt `json:"sort"`
}
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || b.PackID == "" || b.Body == "" {
httpx.Error(w, http.StatusBadRequest, "invalid body")
httpx.Error(w, http.StatusBadRequest, "بسته و متن لازم است")
return
}
if _, err := h.db.ExecContext(r.Context(),
`INSERT INTO chat_messages (pack_id,body,sort) VALUES (?,?,?)`, b.PackID, b.Body, b.Sort); err != nil {
`INSERT INTO chat_messages (pack_id,body,sort) VALUES (?,?,?)`, b.PackID, b.Body, b.Sort.i()); err != nil {
httpx.Error(w, http.StatusInternalServerError, err.Error())
return
}
@@ -452,13 +515,13 @@ func (h *Handler) apiTournaments(w http.ResponseWriter, r *http.Request) {
}
type tournamentBody struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
EntryFee int64 `json:"entry_fee"`
Prizes []int64 `json:"prizes"`
StartsAt string `json:"starts_at"` // datetime-local به وقتِ تهران
EndsAt string `json:"ends_at"`
ID flexInt `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
EntryFee flexInt `json:"entry_fee"`
Prizes []flexInt `json:"prizes"`
StartsAt string `json:"starts_at"` // datetime-local به وقتِ تهران
EndsAt string `json:"ends_at"`
}
// tournamentFromBody بدنه را به economy.Tournament (با زمانِ UTC) تبدیل می‌کند.
@@ -471,9 +534,13 @@ func tournamentFromBody(b tournamentBody) economy.Tournament {
}
return t.UTC()
}
prizes := make([]int64, len(b.Prizes))
for i, p := range b.Prizes {
prizes[i] = p.i()
}
return economy.Tournament{
Title: b.Title, Description: b.Description, EntryFee: b.EntryFee,
Prizes: b.Prizes, StartsAt: parse(b.StartsAt), EndsAt: parse(b.EndsAt),
Title: b.Title, Description: b.Description, EntryFee: b.EntryFee.i(),
Prizes: prizes, StartsAt: parse(b.StartsAt), EndsAt: parse(b.EndsAt),
}
}
@@ -496,7 +563,7 @@ func (h *Handler) apiTournamentUpdate(w http.ResponseWriter, r *http.Request) {
httpx.Error(w, http.StatusBadRequest, "invalid body")
return
}
if err := h.eco.UpdateTournament(r.Context(), b.ID, tournamentFromBody(b)); err != nil {
if err := h.eco.UpdateTournament(r.Context(), b.ID.i(), tournamentFromBody(b)); err != nil {
httpx.Error(w, http.StatusInternalServerError, err.Error())
return
}