feat: add streak

This commit is contained in:
2026-07-07 21:10:01 +03:30
parent e8396db45c
commit e7b2e05428
4 changed files with 117 additions and 25 deletions
+77 -20
View File
@@ -61,15 +61,19 @@ type Profile struct {
RankTier string `json:"rank_tier"` // bronze..king
RankIndex int `json:"rank_index"` // ۰..۴
Avatar string `json:"avatar"` // seed آواتار (برای نمایش سرِ میز)
DailyStreak int `json:"daily_streak"`
BestStreak int `json:"best_streak"`
DailyReady bool `json:"daily_ready"` // آیا امروز سکه‌ی روزانه قابلِ دریافت است
NextDaily int64 `json:"next_daily"` // جایزه‌ی روزانه‌ی بعدی (پیش‌نمایش)
}
// GetProfile وضعیت اقتصادی کاربر را می‌خواند.
func (s *Service) GetProfile(ctx context.Context, userID int64) (*Profile, error) {
var p Profile
var vipUntil, avatar sql.NullString
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 FROM users WHERE id = ?`, userID).
Scan(&p.Coins, &p.Tickets, &p.XP, &p.Trophies, &vipUntil, &p.SelectedCard, &p.SelectedCarpet, &p.RankPoints, &avatar)
`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)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
@@ -83,6 +87,21 @@ func (s *Service) GetProfile(ctx context.Context, userID int64) (*Profile, error
p.VIPUntil = &vipUntil.String
p.VIP = parseTS(vipUntil.String).After(nowUTC())
}
// وضعیتِ سکه‌ی روزانه: اگر امروز (تهران) نگرفته، قابلِ دریافت است. اگر یک روز
// جا افتاده باشد، استریکِ بعدی از ۱ شروع می‌شود.
today := tehranDate(nowUTC())
nextStreak := p.DailyStreak + 1
if lastDaily.Valid {
lastDate := tehranDate(parseTS(lastDaily.String))
p.DailyReady = lastDate != today
if lastDate != tehranDate(nowUTC().Add(-24*time.Hour)) && lastDate != today {
nextStreak = 1 // روزی جا افتاده ⇒ ریست
}
} else {
p.DailyReady = true
nextStreak = 1
}
p.NextDaily = streakReward(nextStreak)
return &p, nil
}
@@ -94,32 +113,70 @@ func (s *Service) isVIP(ctx context.Context, userID int64) bool {
return vu.Valid && parseTS(vu.String).After(nowUTC())
}
// ClaimDaily سکه روزانه را در صورت گذشت ۲۴ ساعت می‌دهد.
func (s *Service) ClaimDaily(ctx context.Context, userID int64) (int64, error) {
// tehranDate تاریخِ تقویمیِ تهران (YYYY-MM-DD) را برمی‌گرداند. مرزِ «روز» برای
// استریک، نیمه‌شبِ تهران است تا با حسِ کاربر بخواند.
func tehranDate(t time.Time) string { return t.In(iranZone).Format("2006-01-02") }
// streakReward جایزه‌ی روزِ n اُمِ استریک: پایه + پله (سقفِ روز ۷) + پاداشِ هفتگی.
func streakReward(streak int) int64 {
n := streak
if n > 7 {
n = 7
}
r := int64(DailyReward) + int64(n-1)*50 // روز۱=۲۰۰ … روز۷=۵۰۰
if streak%7 == 0 { // هر ۷ روز پیاپی، پاداشِ ویژه
r += 500
}
return r
}
// ClaimDaily سکه‌ی روزانه را (یک‌بار در هر روزِ تقویمیِ تهران) می‌دهد و استریک را
// به‌روزرسانی می‌کند. برمی‌گرداند: جایزه، استریکِ جدید، بهترین استریک.
func (s *Service) ClaimDaily(ctx context.Context, userID int64) (reward int64, streak int, best int, err error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
return 0, 0, 0, err
}
defer tx.Rollback()
cutoff := nowUTC().Add(-24 * time.Hour).Format(tsLayout)
res, err := tx.ExecContext(ctx,
`UPDATE users SET last_daily_at = ?
WHERE id = ? AND (last_daily_at IS NULL OR last_daily_at <= ?)`,
nowStr(), userID, cutoff)
if err != nil {
return 0, err
var last sql.NullString
if err = tx.QueryRowContext(ctx,
`SELECT last_daily_at, daily_streak, best_streak FROM users WHERE id = ?`, userID).
Scan(&last, &streak, &best); err != nil {
return 0, 0, 0, err
}
if n, _ := res.RowsAffected(); n == 0 {
return 0, ErrTooSoon
today := tehranDate(nowUTC())
if last.Valid {
lastDate := tehranDate(parseTS(last.String))
if lastDate == today {
return 0, 0, 0, ErrTooSoon // امروز قبلاً گرفته
}
if lastDate == tehranDate(nowUTC().Add(-24*time.Hour)) {
streak++ // دیروز گرفته ⇒ ادامه‌ی استریک
} else {
streak = 1 // یک روز جا افتاده ⇒ ریست
}
} else {
streak = 1
}
if err := adjustTx(ctx, tx, userID, CurrencyCoin, DailyReward, "daily_reward", ""); err != nil {
return 0, err
if streak > best {
best = streak
}
if err := tx.Commit(); err != nil {
return 0, err
reward = streakReward(streak)
if _, err = tx.ExecContext(ctx,
`UPDATE users SET last_daily_at = ?, daily_streak = ?, best_streak = ? WHERE id = ?`,
nowStr(), streak, best, userID); err != nil {
return 0, 0, 0, err
}
return DailyReward, nil
if err = adjustTx(ctx, tx, userID, CurrencyCoin, reward, "daily_reward", ""); err != nil {
return 0, 0, 0, err
}
if err = tx.Commit(); err != nil {
return 0, 0, 0, err
}
return reward, streak, best, nil
}
// ClaimAdReward پس از تأیید تبلیغ rewarded، سکه می‌دهد (با ضد تکرار و سقف روزانه).
+32 -3
View File
@@ -4,6 +4,7 @@ import (
"context"
"path/filepath"
"testing"
"time"
"hakemsho/internal/store"
)
@@ -87,10 +88,11 @@ func TestWalletInsufficient(t *testing.T) {
func TestDailyCooldown(t *testing.T) {
svc, id := setup(t)
ctx := context.Background()
if amt, err := svc.ClaimDaily(ctx, id); err != nil || amt != DailyReward {
t.Fatalf("first claim: amt=%d err=%v", amt, err)
// اولین دریافت: استریک ۱، جایزه = DailyReward.
if amt, streak, _, err := svc.ClaimDaily(ctx, id); err != nil || amt != DailyReward || streak != 1 {
t.Fatalf("first claim: amt=%d streak=%d err=%v", amt, streak, err)
}
if _, err := svc.ClaimDaily(ctx, id); err != ErrTooSoon {
if _, _, _, err := svc.ClaimDaily(ctx, id); err != ErrTooSoon {
t.Fatalf("want ErrTooSoon, got %v", err)
}
if c := coinsOf(t, svc, id); c != DailyReward {
@@ -98,6 +100,33 @@ func TestDailyCooldown(t *testing.T) {
}
}
func TestDailyStreakContinuesAndResets(t *testing.T) {
svc, id := setup(t)
ctx := context.Background()
// دیروز گرفته ⇒ امروز استریک به ۲ می‌رسد و جایزه بیشتر می‌شود.
ySet := func(daysAgo int) {
ts := nowUTC().Add(time.Duration(-daysAgo) * 24 * time.Hour).Format(tsLayout)
if _, err := svc.db.Exec(`UPDATE users SET last_daily_at=?, daily_streak=1 WHERE id=?`, ts, id); err != nil {
t.Fatal(err)
}
}
ySet(1)
amt, streak, _, err := svc.ClaimDaily(ctx, id)
if err != nil || streak != 2 || amt != streakReward(2) {
t.Fatalf("continue: amt=%d streak=%d err=%v", amt, streak, err)
}
// دو روز فاصله ⇒ استریک ریست به ۱.
if _, err := svc.db.Exec(
`UPDATE users SET last_daily_at=?, daily_streak=5 WHERE id=?`,
nowUTC().Add(-48*time.Hour).Format(tsLayout), id); err != nil {
t.Fatal(err)
}
_, streak, _, err = svc.ClaimDaily(ctx, id)
if err != nil || streak != 1 {
t.Fatalf("reset: streak=%d err=%v", streak, err)
}
}
func TestAdRewardDedupeAndCredit(t *testing.T) {
svc, id := setup(t)
ctx := context.Background()
+4 -2
View File
@@ -375,9 +375,11 @@ func (h *Handler) Daily(w http.ResponseWriter, r *http.Request) {
httpx.Error(w, http.StatusUnauthorized, "unauthorized")
return
}
switch amount, err := h.svc.ClaimDaily(r.Context(), id); {
switch amount, streak, best, err := h.svc.ClaimDaily(r.Context(), id); {
case err == nil:
httpx.JSON(w, http.StatusOK, map[string]any{"message": "ok", "amount": amount})
httpx.JSON(w, http.StatusOK, map[string]any{
"message": "ok", "amount": amount, "streak": streak, "best": best,
})
case errors.Is(err, ErrTooSoon):
httpx.Error(w, http.StatusTooManyRequests, "already claimed today")
default: