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، سکه می‌دهد (با ضد تکرار و سقف روزانه).