sno-quiz/backend/internal/service/qr_service.go
2025-09-17 22:22:14 +03:00

193 lines
5.2 KiB
Go

package service
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"sno/internal/models"
"sno/internal/repository"
"strconv"
"time"
"github.com/redis/go-redis/v9"
)
// QRService defines the interface for QR code processing logic.
type QRService interface {
ValidatePayload(ctx context.Context, userID int64, payload string) (*models.QRValidateResponse, error)
GenerateUniqueToken(ctx context.Context, qrType string, value string) (string, error)
ValidateUniqueToken(ctx context.Context, token string) (*models.QRTokenData, error)
}
// qrService implements the QRService interface.
type qrService struct {
qrScanRepo repository.QRScanRepository
adminSvc AdminService
quizSvc QuizService
redis *redis.Client
}
// NewQRService creates a new instance of a QR service.
func NewQRService(qrRepo repository.QRScanRepository, adminSvc AdminService, quizSvc QuizService, redisClient *redis.Client) QRService {
return &qrService{
qrScanRepo: qrRepo,
adminSvc: adminSvc,
quizSvc: quizSvc,
redis: redisClient,
}
}
// generateRandomToken generates a secure random token
func (s *qrService) generateRandomToken() (string, error) {
b := make([]byte, 16) // 128 bits
_, err := rand.Read(b)
if err != nil {
return "", err
}
return fmt.Sprintf("%x", b), nil
}
// GenerateUniqueToken creates a new unique QR token and stores it in Redis
func (s *qrService) GenerateUniqueToken(ctx context.Context, qrType string, value string) (string, error) {
token, err := s.generateRandomToken()
if err != nil {
return "", fmt.Errorf("failed to generate token: %w", err)
}
tokenData := &models.QRTokenData{
Type: qrType,
Value: value,
Used: false,
}
// Store in Redis with 30 day expiration
data, err := json.Marshal(tokenData)
if err != nil {
return "", fmt.Errorf("failed to marshal token data: %w", err)
}
key := fmt.Sprintf("qr_token:%s", token)
err = s.redis.Set(ctx, key, data, 30*24*time.Hour).Err()
if err != nil {
return "", fmt.Errorf("failed to store token in Redis: %w", err)
}
return token, nil
}
// ValidateUniqueToken validates a QR token and marks it as used
func (s *qrService) ValidateUniqueToken(ctx context.Context, token string) (*models.QRTokenData, error) {
key := fmt.Sprintf("qr_token:%s", token)
// Get token data from Redis
data, err := s.redis.Get(ctx, key).Bytes()
if err != nil {
if err == redis.Nil {
return nil, errors.New("invalid or expired token")
}
return nil, fmt.Errorf("failed to get token from Redis: %w", err)
}
var tokenData models.QRTokenData
if err := json.Unmarshal(data, &tokenData); err != nil {
return nil, fmt.Errorf("failed to unmarshal token data: %w", err)
}
// Check if token is already used
if tokenData.Used {
return nil, errors.New("token has already been used")
}
// Mark token as used
tokenData.Used = true
updatedData, err := json.Marshal(tokenData)
if err != nil {
return nil, fmt.Errorf("failed to marshal updated token data: %w", err)
}
err = s.redis.Set(ctx, key, updatedData, 30*24*time.Hour).Err()
if err != nil {
return nil, fmt.Errorf("failed to update token in Redis: %w", err)
}
return &tokenData, nil
}
// ValidatePayload validates a QR token and performs the corresponding action.
func (s *qrService) ValidatePayload(ctx context.Context, userID int64, payload string) (*models.QRValidateResponse, error) {
// Validate as unique token
tokenData, err := s.ValidateUniqueToken(ctx, payload)
if err != nil {
return nil, err
}
// Process the validated token data
return s.processTokenData(ctx, userID, tokenData, payload)
}
// processTokenData processes validated token data and returns response
func (s *qrService) processTokenData(ctx context.Context, userID int64, tokenData *models.QRTokenData, payload string) (*models.QRValidateResponse, error) {
scanLog := &models.QRScan{
UserID: userID,
Source: models.InApp,
Value: &payload,
}
switch tokenData.Type {
case "reward":
scanLog.Type = models.QRReward
amount, err := strconv.Atoi(tokenData.Value)
if err != nil {
return nil, fmt.Errorf("invalid reward amount: %s", tokenData.Value)
}
if err := s.adminSvc.GrantStars(ctx, userID, amount); err != nil {
return nil, fmt.Errorf("failed to grant reward: %w", err)
}
// Log the successful scan
_, _ = s.qrScanRepo.Create(ctx, scanLog)
return &models.QRValidateResponse{
Type: "REWARD",
Data: map[string]int{"amount": amount},
}, nil
case "quiz":
scanLog.Type = models.QRQuiz
quizID, err := strconv.Atoi(tokenData.Value)
if err != nil {
return nil, fmt.Errorf("invalid quiz ID: %s", tokenData.Value)
}
quiz, err := s.quizSvc.GetQuizByID(ctx, quizID)
if err != nil {
return nil, fmt.Errorf("failed to get quiz: %w", err)
}
// Log the successful scan
_, _ = s.qrScanRepo.Create(ctx, scanLog)
return &models.QRValidateResponse{
Type: "OPEN_QUIZ",
Data: quiz,
}, nil
case "shop":
scanLog.Type = models.QRShop
// Shop QR codes can be used for various shop-related actions
// For now, just log the scan and return success
_, _ = s.qrScanRepo.Create(ctx, scanLog)
return &models.QRValidateResponse{
Type: "SHOP_ACTION",
Data: map[string]string{"action": tokenData.Value},
}, nil
default:
return nil, fmt.Errorf("unknown token type: %s", tokenData.Type)
}
}