package usecase import ( "context" "crypto/sha256" "encoding/hex" "fmt" "strings" "apps/backend/internal/module/radar/domain" ) // JudgeCandidate is the metered boundary. A successful structured result is // cached by source identity and DemandMap version; cache hits return zero // credits and never call the provider again. func (s *Service) JudgeCandidate(ctx context.Context, ownerUID int64, profile *domain.ServiceProfile, watch *domain.RadarWatch, cand *domain.CandidatePost) (*JudgeResult, int, error) { key := s.judgeCacheKey(ctx, ownerUID, watch, cand) if key != "" { if result, ok := s.cachedJudgeResult(key); ok { return result, 0, nil } } result, credits, err := s.judgeCandidateUncached(ctx, ownerUID, profile, watch, cand) if err == nil && result != nil && result.Cacheable && key != "" { s.storeJudgeResult(key, result) } return result, credits, err } func (s *Service) judgeCacheKey(ctx context.Context, ownerUID int64, watch *domain.RadarWatch, cand *domain.CandidatePost) string { if cand == nil || strings.TrimSpace(cand.ExternalID) == "" { return "" } contextKey := "generic" mapVersion := int64(0) if watch != nil { contextKey = watch.ContextMode + ":" + watch.ProductID if watch.ContextMode == domain.WatchContextProduct && s.Repo != nil { if dm, err := s.GetDemandMap(ctx, ownerUID, watch.ProductID); err == nil && dm != nil { mapVersion = dm.MapVersion } } } raw := fmt.Sprintf("%d|%s|%d|%s|%s|%d", ownerUID, contextKey, mapVersion, cand.ExternalID, cand.Text, cand.PostedAt) sum := sha256.Sum256([]byte(raw)) return hex.EncodeToString(sum[:]) } func (s *Service) cachedJudgeResult(key string) (*JudgeResult, bool) { s.judgeCacheMu.Lock() defer s.judgeCacheMu.Unlock() if s.judgeCache == nil { s.judgeCache = map[string]cachedJudge{} } value, ok := s.judgeCache[key] if !ok || domain.NowNano()-value.createdAt > int64(24*60*60*1e9) { if ok { delete(s.judgeCache, key) } return nil, false } return cloneJudgeResult(value.result), true } func (s *Service) storeJudgeResult(key string, result *JudgeResult) { s.judgeCacheMu.Lock() defer s.judgeCacheMu.Unlock() if s.judgeCache == nil { s.judgeCache = map[string]cachedJudge{} } if len(s.judgeCache) >= 2048 { for oldKey := range s.judgeCache { delete(s.judgeCache, oldKey) break } } s.judgeCache[key] = cachedJudge{result: cloneJudgeResult(result), createdAt: domain.NowNano()} } func cloneJudgeResult(in *JudgeResult) *JudgeResult { if in == nil { return nil } out := *in out.Reasons = append([]domain.OpportunityReason(nil), in.Reasons...) if in.ProductMatch != nil { out.ProductMatch = domain.CloneProductMatch(in.ProductMatch) } return &out }