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
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[:]))
|
|
}
|