You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

284 lines
7.2 KiB
Go

package question
import (
"context"
cryptorand "crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"math/big"
"sort"
"strings"
"time"
domain "knowfoolery/backend/services/question-bank-service/internal/domain/question"
sharedsecurity "knowfoolery/backend/shared/infra/security"
)
// AnswerValidationResult is the output for answer validation.
type AnswerValidationResult struct {
Matched bool
Score float64
Threshold float64
}
// Cache describes random question cache behavior.
type Cache interface {
Get(ctx context.Context, key string) (*domain.Question, bool)
Set(ctx context.Context, key string, q *domain.Question, ttl time.Duration)
Invalidate(ctx context.Context)
}
// Service orchestrates question use-cases.
type Service struct {
repo domain.Repository
cache Cache
randomCacheTTL time.Duration
randomMaxExclusions int
}
// NewService creates a new question service.
func NewService(repo domain.Repository, cache Cache, randomCacheTTL time.Duration, randomMaxExclusions int) *Service {
if randomCacheTTL <= 0 {
randomCacheTTL = 5 * time.Minute
}
if randomMaxExclusions <= 0 {
randomMaxExclusions = 200
}
return &Service{
repo: repo,
cache: cache,
randomCacheTTL: randomCacheTTL,
randomMaxExclusions: randomMaxExclusions,
}
}
// GetRandomQuestion returns a random active question based on optional filters.
func (s *Service) GetRandomQuestion(ctx context.Context, req RandomQuestionRequest) (*domain.Question, error) {
exclusions := normalizeExclusions(req.ExcludeQuestionIDs, s.randomMaxExclusions)
filter := domain.RandomFilter{
ExcludeQuestionIDs: exclusions,
Theme: sharedsecurity.SanitizeTheme(req.Theme),
Difficulty: req.Difficulty,
}
if filter.Difficulty != "" && !domain.IsValidDifficulty(filter.Difficulty) {
return nil, domain.ErrValidationFailed
}
cacheKey := randomQuestionCacheKey(filter)
if s.cache != nil {
if cached, ok := s.cache.Get(ctx, cacheKey); ok {
return cached, nil
}
}
count, err := s.repo.CountRandomCandidates(ctx, filter)
if err != nil {
return nil, err
}
if count == 0 {
return nil, domain.ErrNoQuestionsAvailable
}
offset := 0
if count > 1 {
n, err := cryptorand.Int(cryptorand.Reader, big.NewInt(int64(count)))
if err == nil {
offset = int(n.Int64())
}
}
q, err := s.repo.RandomByOffset(ctx, filter, offset)
if err != nil {
return nil, err
}
if s.cache != nil {
s.cache.Set(ctx, cacheKey, q, s.randomCacheTTL)
}
return q, nil
}
// GetQuestionByID returns a question by identifier.
func (s *Service) GetQuestionByID(ctx context.Context, id string) (*domain.Question, error) {
q, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
return q, nil
}
// ValidateAnswerByQuestionID validates a provided answer against a stored question answer.
func (s *Service) ValidateAnswerByQuestionID(ctx context.Context, id string,
provided string) (*AnswerValidationResult, error) {
provided = sharedsecurity.SanitizeAnswer(provided)
if provided == "" {
return nil, domain.ErrValidationFailed
}
q, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
matched, score := domain.IsAnswerMatch(q.Answer, provided)
return &AnswerValidationResult{
Matched: matched,
Score: score,
Threshold: 0.85,
}, nil
}
// CreateQuestion creates a new question.
func (s *Service) CreateQuestion(ctx context.Context, in CreateQuestionInput) (*domain.Question, error) {
q, err := s.toQuestion(in.Theme, in.Text, in.Answer, in.Hint, in.Difficulty)
if err != nil {
return nil, err
}
created, err := s.repo.Create(ctx, q)
if err != nil {
return nil, err
}
s.invalidateRandomCache(ctx)
return created, nil
}
// UpdateQuestion updates an existing question.
func (s *Service) UpdateQuestion(ctx context.Context, id string, in UpdateQuestionInput) (*domain.Question, error) {
q, err := s.toQuestion(in.Theme, in.Text, in.Answer, in.Hint, in.Difficulty)
if err != nil {
return nil, err
}
q.IsActive = in.IsActive
updated, err := s.repo.Update(ctx, id, q)
if err != nil {
return nil, err
}
s.invalidateRandomCache(ctx)
return updated, nil
}
// DeleteQuestion soft-deletes a question.
func (s *Service) DeleteQuestion(ctx context.Context, id string) error {
if err := s.repo.SoftDelete(ctx, id); err != nil {
return err
}
s.invalidateRandomCache(ctx)
return nil
}
// ListThemes returns all active themes.
func (s *Service) ListThemes(ctx context.Context) ([]string, error) {
return s.repo.ListThemes(ctx)
}
// BulkImport imports questions in batch with partial failure reporting.
func (s *Service) BulkImport(ctx context.Context, items []BulkImportItem, maxItems int) (*BulkImportResult, error) {
if maxItems > 0 && len(items) > maxItems {
return nil, domain.ErrValidationFailed
}
questions := make([]*domain.Question, 0, len(items))
errList := make([]domain.BulkError, 0)
for i, item := range items {
sanitized, err := sanitizeAndValidateItem(item)
if err != nil {
errList = append(errList, domain.BulkError{Index: i, Reason: err.Error()})
continue
}
q, err := s.toQuestion(sanitized.Theme, sanitized.Text, sanitized.Answer, sanitized.Hint, sanitized.Difficulty)
if err != nil {
errList = append(errList, domain.BulkError{Index: i, Reason: err.Error()})
continue
}
questions = append(questions, q)
}
createdCount := 0
if len(questions) > 0 {
created, bulkErrs, err := s.repo.BulkCreate(ctx, questions)
if err != nil {
return nil, err
}
createdCount = created
errList = append(errList, bulkErrs...)
}
s.invalidateRandomCache(ctx)
return &BulkImportResult{
CreatedCount: createdCount,
FailedCount: len(items) - createdCount,
Errors: errList,
}, nil
}
func (s *Service) toQuestion(theme, text, answer, hint string, difficulty domain.Difficulty) (*domain.Question, error) {
theme = sharedsecurity.SanitizeTheme(theme)
text = sharedsecurity.SanitizeQuestionText(text)
answer = sharedsecurity.SanitizeAnswer(answer)
hint = sharedsecurity.SanitizeQuestionText(hint)
if !domain.IsValidDifficulty(difficulty) {
return nil, domain.ErrValidationFailed
}
if theme == "" || text == "" || answer == "" {
return nil, domain.ErrValidationFailed
}
return &domain.Question{
Theme: theme,
Text: text,
Answer: answer,
Hint: hint,
Difficulty: difficulty,
IsActive: true,
}, nil
}
func (s *Service) invalidateRandomCache(ctx context.Context) {
if s.cache != nil {
s.cache.Invalidate(ctx)
}
}
func normalizeExclusions(in []string, max int) []string {
if len(in) == 0 {
return nil
}
unique := make(map[string]struct{}, len(in))
out := make([]string, 0, len(in))
for _, id := range in {
id = strings.TrimSpace(id)
if id == "" {
continue
}
if _, exists := unique[id]; exists {
continue
}
unique[id] = struct{}{}
out = append(out, id)
if len(out) >= max {
break
}
}
sort.Strings(out)
return out
}
func randomQuestionCacheKey(filter domain.RandomFilter) string {
payload, _ := json.Marshal(map[string]interface{}{
"theme": strings.ToLower(strings.TrimSpace(filter.Theme)),
"difficulty": filter.Difficulty,
"exclude_ids": filter.ExcludeQuestionIDs,
})
h := sha256.Sum256(payload)
return fmt.Sprintf("qb:random:v1:%s", hex.EncodeToString(h[:]))
}