feat: add streak
This commit is contained in:
+77
-20
@@ -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، سکه میدهد (با ضد تکرار و سقف روزانه).
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- استریکِ روزانه (مثلِ دولینگو): تعدادِ روزهای پیاپی که کاربر سکهی روزانه گرفته.
|
||||
-- با از دست دادنِ یک روز صفر میشود؛ جایزه با طولِ استریک زیاد میشود.
|
||||
ALTER TABLE users ADD COLUMN daily_streak INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE users ADD COLUMN best_streak INTEGER NOT NULL DEFAULT 0;
|
||||
Reference in New Issue
Block a user