mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
dev day #80
This commit is contained in:
+153
-41
@@ -1,11 +1,16 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/datatypes"
|
||||
|
||||
"fotbal-club/internal/models"
|
||||
)
|
||||
@@ -21,38 +26,62 @@ type EngagementService struct {
|
||||
// - comment_create: max 10 awards per day
|
||||
// - newsletter_subscribe: once per lifetime
|
||||
func (s *EngagementService) AwardPointsCapped(userID uint, delta int64, reason string, meta map[string]interface{}) (*models.UserProfile, error) {
|
||||
if userID == 0 || delta == 0 { return nil, nil }
|
||||
if !s.canAwardMore(userID, strings.TrimSpace(reason)) {
|
||||
return s.EnsureProfile(userID) // return current profile without adding
|
||||
}
|
||||
return s.AwardPoints(userID, delta, reason, meta)
|
||||
if userID == 0 || delta == 0 { return nil, nil }
|
||||
if !s.canAwardMore(userID, strings.TrimSpace(reason)) {
|
||||
return s.EnsureProfile(userID) // return current profile without adding
|
||||
}
|
||||
return s.AwardPoints(userID, delta, reason, meta)
|
||||
}
|
||||
|
||||
func (s *EngagementService) canAwardMore(userID uint, reason string) bool {
|
||||
now := time.Now()
|
||||
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
switch reason {
|
||||
case "poll_vote":
|
||||
var cnt int64
|
||||
_ = s.DB.Model(&models.PointsTransaction{}).
|
||||
Where("user_id = ? AND reason = ? AND created_at >= ?", userID, "poll_vote", startOfDay).
|
||||
Count(&cnt).Error
|
||||
return cnt < 1
|
||||
case "comment_create":
|
||||
var cnt int64
|
||||
_ = s.DB.Model(&models.PointsTransaction{}).
|
||||
Where("user_id = ? AND reason = ? AND created_at >= ?", userID, "comment_create", startOfDay).
|
||||
Count(&cnt).Error
|
||||
return cnt < 10
|
||||
case "newsletter_subscribe":
|
||||
var cnt int64
|
||||
_ = s.DB.Model(&models.PointsTransaction{}).
|
||||
Where("user_id = ? AND reason = ?", userID, "newsletter_subscribe").
|
||||
Count(&cnt).Error
|
||||
return cnt == 0
|
||||
default:
|
||||
return true
|
||||
}
|
||||
now := time.Now()
|
||||
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
switch reason {
|
||||
case "poll_vote":
|
||||
var cnt int64
|
||||
_ = s.DB.Model(&models.PointsTransaction{}).
|
||||
Where("user_id = ? AND reason = ? AND created_at >= ?", userID, "poll_vote", startOfDay).
|
||||
Count(&cnt).Error
|
||||
return cnt < 1
|
||||
case "comment_create":
|
||||
var cnt int64
|
||||
_ = s.DB.Model(&models.PointsTransaction{}).
|
||||
Where("user_id = ? AND reason = ? AND created_at >= ?", userID, "comment_create", startOfDay).
|
||||
Count(&cnt).Error
|
||||
return cnt < 3
|
||||
case "comment_reacted":
|
||||
var cnt int64
|
||||
_ = s.DB.Model(&models.PointsTransaction{}).
|
||||
Where("user_id = ? AND reason = ? AND created_at >= ?", userID, "comment_reacted", startOfDay).
|
||||
Count(&cnt).Error
|
||||
return cnt < 10
|
||||
case "newsletter_subscribe":
|
||||
var cnt int64
|
||||
_ = s.DB.Model(&models.PointsTransaction{}).
|
||||
Where("user_id = ? AND reason = ?", userID, "newsletter_subscribe").
|
||||
Count(&cnt).Error
|
||||
return cnt == 0
|
||||
case "daily_checkin":
|
||||
var cnt int64
|
||||
_ = s.DB.Model(&models.PointsTransaction{}).
|
||||
Where("user_id = ? AND reason = ? AND created_at >= ?", userID, "daily_checkin", startOfDay).
|
||||
Count(&cnt).Error
|
||||
return cnt < 1
|
||||
case "article_read":
|
||||
var cnt int64
|
||||
_ = s.DB.Model(&models.PointsTransaction{}).
|
||||
Where("user_id = ? AND reason = ? AND created_at >= ?", userID, "article_read", startOfDay).
|
||||
Count(&cnt).Error
|
||||
return cnt < 3
|
||||
case "newsletter_click":
|
||||
var cnt int64
|
||||
_ = s.DB.Model(&models.PointsTransaction{}).
|
||||
Where("user_id = ? AND reason = ? AND created_at >= ?", userID, "newsletter_click", startOfDay).
|
||||
Count(&cnt).Error
|
||||
return cnt < 3
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func NewEngagementService(db *gorm.DB) *EngagementService { return &EngagementService{DB: db} }
|
||||
@@ -62,30 +91,112 @@ func (s *EngagementService) EnsureProfile(userID uint) (*models.UserProfile, err
|
||||
var up models.UserProfile
|
||||
if err := s.DB.Where("user_id = ?", userID).First(&up).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
up = models.UserProfile{UserID: userID, Points: 0, Level: 1, XP: 0}
|
||||
// Create with sensible defaults: level 1, 0 points/xp, autogenerated username and avatar
|
||||
// Load basic user info for username suggestion
|
||||
var u models.User
|
||||
_ = s.DB.Select("first_name", "last_name", "email").First(&u, userID).Error
|
||||
// Helper to generate a random hex suffix
|
||||
randHex := func(n int) string {
|
||||
b := make([]byte, n)
|
||||
if _, e := rand.Read(b); e != nil { return fmt.Sprintf("%d", time.Now().UnixNano()) }
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
// Build base username from name or email local part
|
||||
base := strings.TrimSpace(strings.ToLower(strings.Join([]string{strings.TrimSpace(u.FirstName), strings.TrimSpace(u.LastName)}, " ")))
|
||||
if base == "" && u.Email != "" {
|
||||
if idx := strings.Index(u.Email, "@"); idx > 0 { base = u.Email[:idx] }
|
||||
}
|
||||
if base == "" { base = "fan" }
|
||||
// Normalize to [a-z0-9-_]
|
||||
norm := make([]rune, 0, len(base))
|
||||
for _, r := range base {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' {
|
||||
norm = append(norm, r)
|
||||
} else if r == ' ' || r == '.' {
|
||||
norm = append(norm, '-')
|
||||
}
|
||||
}
|
||||
candidate := strings.Trim(strings.Trim(strings.ReplaceAll(string(norm), "--", "-"), "-"), "_")
|
||||
if candidate == "" { candidate = "fan" }
|
||||
// Ensure uniqueness by appending suffixes when needed
|
||||
username := candidate
|
||||
for i := 0; i < 5; i++ {
|
||||
var cnt int64
|
||||
_ = s.DB.Model(&models.UserProfile{}).Where("LOWER(username) = LOWER(?)", username).Count(&cnt).Error
|
||||
if cnt == 0 { break }
|
||||
username = fmt.Sprintf("%s-%s", candidate, randHex(2))
|
||||
}
|
||||
// Default avatar (Dicebear pixel-art)
|
||||
seed := username
|
||||
if seed == "" { seed = randHex(3) }
|
||||
avatar := fmt.Sprintf("https://api.dicebear.com/7.x/pixel-art/svg?radius=50&seed=%s", url.QueryEscape(seed))
|
||||
|
||||
up = models.UserProfile{UserID: userID, Points: 0, Level: 1, XP: 0, Username: username, AvatarURL: avatar}
|
||||
if err := s.DB.Create(&up).Error; err != nil { return nil, err }
|
||||
return &up, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
// Backfill missing fields for older profiles
|
||||
changed := false
|
||||
if strings.TrimSpace(up.Username) == "" {
|
||||
var u models.User
|
||||
_ = s.DB.Select("first_name", "last_name", "email").First(&u, userID).Error
|
||||
base := strings.TrimSpace(strings.ToLower(strings.Join([]string{strings.TrimSpace(u.FirstName), strings.TrimSpace(u.LastName)}, " ")))
|
||||
if base == "" && u.Email != "" { if idx := strings.Index(u.Email, "@"); idx > 0 { base = u.Email[:idx] } }
|
||||
if base == "" { base = "fan" }
|
||||
norm := make([]rune, 0, len(base))
|
||||
for _, r := range base {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' {
|
||||
norm = append(norm, r)
|
||||
} else if r == ' ' || r == '.' { norm = append(norm, '-') }
|
||||
}
|
||||
candidate := strings.Trim(strings.Trim(strings.ReplaceAll(string(norm), "--", "-"), "-"), "_")
|
||||
if candidate == "" { candidate = "fan" }
|
||||
username := candidate
|
||||
for i := 0; i < 5; i++ {
|
||||
var cnt int64
|
||||
_ = s.DB.Model(&models.UserProfile{}).Where("LOWER(username) = LOWER(?)", username).Count(&cnt).Error
|
||||
if cnt == 0 { break }
|
||||
username = fmt.Sprintf("%s-%d", candidate, time.Now().Unix()%10000)
|
||||
}
|
||||
up.Username = username
|
||||
changed = true
|
||||
}
|
||||
if strings.TrimSpace(up.AvatarURL) == "" {
|
||||
b := make([]byte, 3); _, _ = rand.Read(b)
|
||||
seed := hex.EncodeToString(b)
|
||||
up.AvatarURL = fmt.Sprintf("https://api.dicebear.com/7.x/pixel-art/svg?radius=50&seed=%s", url.QueryEscape(seed))
|
||||
changed = true
|
||||
}
|
||||
if changed { _ = s.DB.Model(&models.UserProfile{}).Where("user_id = ?", userID).Updates(map[string]interface{}{"username": up.Username, "avatar_url": up.AvatarURL}).Error }
|
||||
return &up, nil
|
||||
}
|
||||
|
||||
// AwardPoints adds points/xp and logs transaction; returns updated profile
|
||||
// AwardPoints adds points/xp (xp mirrors points by default) and logs transaction; returns updated profile
|
||||
func (s *EngagementService) AwardPoints(userID uint, delta int64, reason string, meta map[string]interface{}) (*models.UserProfile, error) {
|
||||
if userID == 0 || delta == 0 { return nil, nil }
|
||||
// admin_adjust should not affect XP
|
||||
xpDelta := delta
|
||||
if strings.TrimSpace(reason) == "admin_adjust" { xpDelta = 0 }
|
||||
return s.AwardPointsAndXP(userID, delta, xpDelta, reason, meta)
|
||||
}
|
||||
|
||||
// AwardPointsAndXP adds points and XP separately and logs transaction; returns updated profile
|
||||
func (s *EngagementService) AwardPointsAndXP(userID uint, pointsDelta int64, xpDelta int64, reason string, meta map[string]interface{}) (*models.UserProfile, error) {
|
||||
if userID == 0 || (pointsDelta == 0 && xpDelta == 0) { return nil, nil }
|
||||
if _, err := s.EnsureProfile(userID); err != nil { return nil, err }
|
||||
pt := models.PointsTransaction{ UserID: userID, Delta: delta, XPDelta: delta, Reason: strings.TrimSpace(reason) }
|
||||
if meta != nil { pt.Meta = meta }
|
||||
pt := models.PointsTransaction{ UserID: userID, Delta: pointsDelta, XPDelta: xpDelta, Reason: strings.TrimSpace(reason) }
|
||||
if meta != nil { pt.Meta = datatypes.JSONMap(meta) }
|
||||
if err := s.DB.Create(&pt).Error; err != nil { return nil, err }
|
||||
// Update profile atomically
|
||||
if err := s.DB.Model(&models.UserProfile{}).Where("user_id = ?", userID).
|
||||
Updates(map[string]interface{}{
|
||||
"points": gorm.Expr("points + ?", delta),
|
||||
"xp": gorm.Expr("xp + ?", delta),
|
||||
"updated_at": time.Now(),
|
||||
}).Error; err != nil { return nil, err }
|
||||
// Recompute level
|
||||
updates := map[string]interface{}{
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
if pointsDelta != 0 { updates["points"] = gorm.Expr("points + ?", pointsDelta) }
|
||||
if xpDelta != 0 { updates["xp"] = gorm.Expr("xp + ?", xpDelta) }
|
||||
if err := s.DB.Model(&models.UserProfile{}).Where("user_id = ?", userID).Updates(updates).Error; err != nil { return nil, err }
|
||||
// Recompute level based on XP
|
||||
var up models.UserProfile
|
||||
if err := s.DB.Where("user_id = ?", userID).First(&up).Error; err != nil { return nil, err }
|
||||
lvl := ComputeLevel(up.XP)
|
||||
@@ -152,7 +263,8 @@ func (s *EngagementService) CheckAndAwardAchievements(userID uint) error {
|
||||
var existing models.UserAchievement
|
||||
if err := s.DB.Where("user_id = ? AND achievement_id = ?", userID, a.ID).First(&existing).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
_ = s.DB.Create(&models.UserAchievement{UserID: userID, AchievementID: a.ID}).Error
|
||||
_, _ = s.AwardPoints(userID, a.Points, "achievement:"+code, map[string]interface{}{"achievement_id": a.ID})
|
||||
// Award both points and XP as defined by achievement
|
||||
_, _ = s.AwardPointsAndXP(userID, a.Points, a.XP, "achievement:"+code, map[string]interface{}{"achievement_id": a.ID})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/models"
|
||||
"fotbal-club/pkg/email"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// SweepstakesService encapsulates business logic for sweepstakes
|
||||
type SweepstakesService struct {
|
||||
DB *gorm.DB
|
||||
Email email.EmailService
|
||||
}
|
||||
|
||||
func NewSweepstakesService(db *gorm.DB, es email.EmailService) *SweepstakesService {
|
||||
return &SweepstakesService{DB: db, Email: es}
|
||||
}
|
||||
|
||||
// StartSweepstakesScheduler periodically finalizes ended sweepstakes and picks winners
|
||||
func StartSweepstakesScheduler(db *gorm.DB, es email.EmailService) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if err := finalizeDueSweepstakes(db, es); err != nil {
|
||||
log.Printf("[sweepstakes] finalize cycle error: %v", err)
|
||||
}
|
||||
<-ticker.C
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func finalizeDueSweepstakes(db *gorm.DB, es email.EmailService) error {
|
||||
// Find sweepstakes that ended and have not selected winners yet
|
||||
var list []models.Sweepstake
|
||||
now := time.Now()
|
||||
if err := db.Where("end_at <= ? AND (winners_selected_at IS NULL) AND status <> ?", now, "finalized").Find(&list).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
svc := NewSweepstakesService(db, es)
|
||||
for _, s := range list {
|
||||
if err := svc.FinalizeSweepstake(&s, ""); err != nil {
|
||||
log.Printf("[sweepstakes] finalize %d failed: %v", s.ID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FinalizeSweepstake selects winners deterministically (if DrawSeed exists) and updates records.
|
||||
// If seed is empty and sweepstake.DrawSeed empty, a new seed is generated from time.
|
||||
func (s *SweepstakesService) FinalizeSweepstake(sw *models.Sweepstake, seed string) error {
|
||||
return s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// Reload for update to avoid races
|
||||
var cur models.Sweepstake
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&cur, sw.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if cur.WinnersSelectedAt != nil {
|
||||
return nil // already finalized
|
||||
}
|
||||
// Load entries
|
||||
var entries []models.SweepstakeEntry
|
||||
if err := tx.Where("sweepstake_id = ? AND status = ?", cur.ID, "valid").Find(&entries).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Load prizes
|
||||
var prizes []models.SweepstakePrize
|
||||
if err := tx.Where("sweepstake_id = ?", cur.ID).Order("display_order ASC, id ASC").Find(&prizes).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Determine number of winners
|
||||
nWinners := 0
|
||||
for _, p := range prizes { nWinners += max(0, p.Quantity) }
|
||||
if nWinners == 0 {
|
||||
if cur.TotalPrizes > 0 { nWinners = cur.TotalPrizes }
|
||||
}
|
||||
if nWinners > len(entries) { nWinners = len(entries) }
|
||||
|
||||
// Build seed
|
||||
effSeed := strings.TrimSpace(seed)
|
||||
if effSeed == "" { effSeed = strings.TrimSpace(cur.DrawSeed) }
|
||||
if effSeed == "" { effSeed = fmt.Sprintf("%d-%d", cur.ID, time.Now().UnixNano()) }
|
||||
// Deterministic RNG from SHA-256
|
||||
h := sha256.Sum256([]byte(effSeed))
|
||||
base := binary.LittleEndian.Uint64(h[:8])
|
||||
rng := rand.New(rand.NewSource(int64(base)))
|
||||
|
||||
// Shuffle indices
|
||||
idx := rng.Perm(len(entries))
|
||||
picked := make(map[uint]bool)
|
||||
winners := make([]models.SweepstakeWinner, 0, nWinners)
|
||||
assign := func(userID uint, entryID uint, prize *models.SweepstakePrize) {
|
||||
w := models.SweepstakeWinner{
|
||||
SweepstakeID: cur.ID,
|
||||
EntryID: entryID,
|
||||
UserID: userID,
|
||||
ClaimStatus: "pending",
|
||||
}
|
||||
if prize != nil {
|
||||
w.PrizeID = &prize.ID
|
||||
w.PrizeName = prize.Name
|
||||
}
|
||||
winners = append(winners, w)
|
||||
}
|
||||
|
||||
pos := 0
|
||||
// Assign by prizes first
|
||||
for i := range prizes {
|
||||
q := max(0, prizes[i].Quantity)
|
||||
for j := 0; j < q && pos < len(idx); j++ {
|
||||
cand := entries[idx[pos]]
|
||||
pos++
|
||||
if picked[cand.UserID] { j--; continue }
|
||||
picked[cand.UserID] = true
|
||||
assign(cand.UserID, cand.ID, &prizes[i])
|
||||
if len(winners) >= nWinners { break }
|
||||
}
|
||||
if len(winners) >= nWinners { break }
|
||||
}
|
||||
// If still need more (when TotalPrizes used)
|
||||
for len(winners) < nWinners && pos < len(idx) {
|
||||
cand := entries[idx[pos]]
|
||||
pos++
|
||||
if picked[cand.UserID] { continue }
|
||||
picked[cand.UserID] = true
|
||||
assign(cand.UserID, cand.ID, nil)
|
||||
}
|
||||
|
||||
// Persist winners
|
||||
for i := range winners {
|
||||
if err := tx.Create(&winners[i]).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
vis := cur.EndAt.Add(72 * time.Hour)
|
||||
if err := tx.Model(&models.Sweepstake{}).Where("id = ?", cur.ID).Updates(map[string]interface{}{
|
||||
"winners_selected_at": now,
|
||||
"visibility_until": vis,
|
||||
"draw_seed": effSeed,
|
||||
"status": "finalized",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Send emails (best-effort)
|
||||
if s.Email != nil && len(winners) > 0 {
|
||||
for _, w := range winners {
|
||||
var user models.User
|
||||
_ = tx.First(&user, w.UserID).Error
|
||||
if strings.TrimSpace(user.Email) == "" { continue }
|
||||
_ = s.Email.SendEmail(&email.EmailData{
|
||||
Subject: "Vyhráli jste v soutěži!",
|
||||
To: []string{strings.TrimSpace(user.Email)},
|
||||
Template: "sweepstake_winner_user",
|
||||
Data: map[string]interface{}{
|
||||
"Title": cur.Title,
|
||||
"PrizeName": w.PrizeName,
|
||||
"EndsAt": cur.EndAt.Format(time.RFC1123),
|
||||
},
|
||||
})
|
||||
}
|
||||
// Admin summary
|
||||
var set models.Settings
|
||||
_ = tx.First(&set).Error
|
||||
adminTo := strings.TrimSpace(set.ContactEmail)
|
||||
if adminTo == "" { adminTo = strings.TrimSpace(set.SMTPFrom) }
|
||||
if adminTo != "" {
|
||||
_ = s.Email.SendEmail(&email.EmailData{
|
||||
Subject: "Soutěž – vybraní výherci",
|
||||
To: []string{adminTo},
|
||||
Template: "sweepstake_winner_admin",
|
||||
Data: map[string]interface{}{
|
||||
"Title": cur.Title,
|
||||
"WinnersCount": len(winners),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func max(a, b int) int { if a > b { return a } ; return b }
|
||||
Reference in New Issue
Block a user