feat: add frame

This commit is contained in:
2026-07-08 12:34:21 +03:30
parent 525cd554ef
commit b5420bac21
12 changed files with 280 additions and 13 deletions
+3 -2
View File
@@ -57,6 +57,7 @@ type Profile struct {
VIPUntil *string `json:"vip_until"`
SelectedCard string `json:"selected_card"`
SelectedCarpet string `json:"selected_carpet"`
SelectedFrame string `json:"selected_frame"`
RankPoints int64 `json:"rank_points"`
RankTier string `json:"rank_tier"` // bronze..king
RankIndex int `json:"rank_index"` // ۰..۴
@@ -72,8 +73,8 @@ func (s *Service) GetProfile(ctx context.Context, userID int64) (*Profile, error
var p Profile
var vipUntil, avatar, lastDaily sql.NullString
err := s.db.QueryRowContext(ctx,
`SELECT coins, tickets, xp, trophies, vip_until, selected_card, selected_carpet, rank_points, avatar, daily_streak, best_streak, last_daily_at FROM users WHERE id = ?`, userID).
Scan(&p.Coins, &p.Tickets, &p.XP, &p.Trophies, &vipUntil, &p.SelectedCard, &p.SelectedCarpet, &p.RankPoints, &avatar, &p.DailyStreak, &p.BestStreak, &lastDaily)
`SELECT coins, tickets, xp, trophies, vip_until, selected_card, selected_carpet, selected_frame, rank_points, avatar, daily_streak, best_streak, last_daily_at FROM users WHERE id = ?`, userID).
Scan(&p.Coins, &p.Tickets, &p.XP, &p.Trophies, &vipUntil, &p.SelectedCard, &p.SelectedCarpet, &p.SelectedFrame, &p.RankPoints, &avatar, &p.DailyStreak, &p.BestStreak, &lastDaily)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
+118
View File
@@ -0,0 +1,118 @@
package economy
import "context"
// Frame یک قابِ آواتار (کازمتیک). Owned یعنی قابلِ انتخاب. سبکِ رنگی سمتِ کلاینت است.
type Frame struct {
ID string `json:"id"`
Title string `json:"title"`
PriceCoins int64 `json:"price_coins"`
VIP bool `json:"vip"`
Owned bool `json:"owned"`
}
// Frames قاب‌های فعال را با وضعیتِ مالکیتِ کاربر برمی‌گرداند.
func (s *Service) Frames(ctx context.Context, userID int64) ([]Frame, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, title, price_coins, vip FROM avatar_frames WHERE enabled = 1 ORDER BY sort`)
if err != nil {
return nil, err
}
defer rows.Close()
var frames []Frame
for rows.Next() {
var f Frame
var vip int
if err := rows.Scan(&f.ID, &f.Title, &f.PriceCoins, &vip); err != nil {
return nil, err
}
f.VIP = vip == 1
frames = append(frames, f)
}
if err := rows.Err(); err != nil {
return nil, err
}
owned, err := s.ownedFrames(ctx, userID)
if err != nil {
return nil, err
}
isVIP := s.isVIP(ctx, userID)
for i := range frames {
f := &frames[i]
f.Owned = (f.PriceCoins == 0 && !f.VIP) || (f.VIP && isVIP) || owned[f.ID]
}
return frames, nil
}
func (s *Service) ownedFrames(ctx context.Context, userID int64) (map[string]bool, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT frame_id FROM user_frames 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()
}
// BuyFrame یک قاب را با سکه باز می‌کند.
func (s *Service) BuyFrame(ctx context.Context, userID int64, frameID string) error {
var price int64
var vip, enabled int
err := s.db.QueryRowContext(ctx,
`SELECT price_coins, vip, enabled FROM avatar_frames WHERE id = ?`, frameID).
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_frames WHERE user_id = ? AND frame_id = ?`, userID, frameID).Scan(&exists)
if exists == 1 {
return ErrAlreadyOwned
}
if price > 0 {
if err := adjustTx(ctx, tx, userID, CurrencyCoin, -price, "buy_frame", frameID); err != nil {
return err
}
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO user_frames (user_id, frame_id) VALUES (?, ?)`, userID, frameID); err != nil {
return err
}
return tx.Commit()
}
// SelectFrame قابِ انتخابیِ کاربر را تنظیم می‌کند (باید مالکش باشد یا رایگان/VIP).
func (s *Service) SelectFrame(ctx context.Context, userID int64, frameID string) error {
frames, err := s.Frames(ctx, userID)
if err != nil {
return err
}
for _, f := range frames {
if f.ID == frameID {
if !f.Owned {
return ErrNotOwned
}
_, err := s.db.ExecContext(ctx,
`UPDATE users SET selected_frame = ? WHERE id = ?`, frameID, userID)
return err
}
}
return ErrNotFound
}
+64
View File
@@ -155,6 +155,70 @@ func (h *Handler) carpetAction(w http.ResponseWriter, r *http.Request, sel bool)
}
}
// Frames — GET /api/frames (قاب‌های آواتار + مالکیت + انتخابِ کاربر)
func (h *Handler) Frames(w http.ResponseWriter, r *http.Request) {
id, ok := uid(r)
if !ok {
httpx.Error(w, http.StatusUnauthorized, "unauthorized")
return
}
frames, err := h.svc.Frames(r.Context(), id)
if err != nil {
httpx.Error(w, http.StatusInternalServerError, "server error")
return
}
selected := ""
if p, _ := h.svc.GetProfile(r.Context(), id); p != nil {
selected = p.SelectedFrame
}
httpx.JSON(w, http.StatusOK, map[string]any{"frames": frames, "selected": selected})
}
// BuyFrame — POST /api/shop/buy-frame
func (h *Handler) BuyFrame(w http.ResponseWriter, r *http.Request) {
h.frameAction(w, r, false)
}
// SelectFrame — POST /api/shop/select-frame
func (h *Handler) SelectFrame(w http.ResponseWriter, r *http.Request) {
h.frameAction(w, r, true)
}
func (h *Handler) frameAction(w http.ResponseWriter, r *http.Request, sel bool) {
id, ok := uid(r)
if !ok {
httpx.Error(w, http.StatusUnauthorized, "unauthorized")
return
}
var req struct {
FrameID string `json:"frame_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.SelectFrame(r.Context(), id, req.FrameID)
} else {
err = h.svc.BuyFrame(r.Context(), id, req.FrameID)
}
switch {
case err == nil:
httpx.JSON(w, http.StatusOK, map[string]string{"message": "ok"})
case errors.Is(err, ErrNotFound):
httpx.Error(w, http.StatusNotFound, "frame not found")
case errors.Is(err, ErrAlreadyOwned):
httpx.Error(w, http.StatusConflict, "already owned")
case errors.Is(err, ErrNotOwned):
httpx.Error(w, http.StatusForbidden, "frame 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 (بسته‌های پیام/شکلک + مالکیتِ کاربر)
func (h *Handler) ChatPacks(w http.ResponseWriter, r *http.Request) {
id, ok := uid(r)