package usecase import ( "context" "fmt" "strings" "time" "apps/backend/internal/module/radar/domain" usageDomain "apps/backend/internal/module/usage/domain" ) // GetCostPreview is deliberately read-only: it resolves the likely key mode // and reads the current balance, but never calls PrepareCall or reserves a // credit. The actual operation remains the only place that meters usage. func (s *Service) GetCostPreview(ctx context.Context, ownerUID int64, action, watchID, productID string, candidateLimit int) (*domain.CostPreview, error) { if ownerUID <= 0 { return nil, fmt.Errorf("%w: owner_uid required", domain.ErrValidation) } action = strings.ToLower(strings.TrimSpace(action)) if action != "sweep" && action != "explore" && action != "demand_map_enrich" && action != "reply" { return nil, fmt.Errorf("%w: unknown cost preview action %q", domain.ErrValidation, action) } if candidateLimit <= 0 { candidateLimit = 20 } if candidateLimit > 100 { candidateLimit = 100 } searchCalls := 0 if action == "sweep" || action == "explore" { searchCalls = 1 if productID != "" { if plan, err := s.BuildProductQueryPlan(ctx, ownerUID, productID); err == nil && len(plan.Groups) > 0 { searchCalls = len(plan.Groups) } } } maxAI := 0 fixed := 0 min := searchCalls * usageDomain.MeterCost(usageDomain.MeterWebSearch) max := min switch action { case "sweep", "explore": maxAI = candidateLimit max += maxAI * usageDomain.MeterCost(usageDomain.MeterAIResearch) case "demand_map_enrich", "reply": fixed = usageDomain.MeterCost(usageDomain.MeterAICopy) min, max = fixed, fixed } mode := usageDomain.KeyModePlatform meter := usageDomain.MeterWebSearch if action == "demand_map_enrich" || action == "reply" { meter = usageDomain.MeterAICopy } if s.ResolveKey != nil { if resolved, _, err := s.ResolveKey(ctx, ownerUID, meter); err == nil && resolved != "" { mode = resolved } } remaining := 0 if s.Usage != nil { if summary, err := s.Usage.GetSummary(ctx, ownerUID, ""); err == nil && summary != nil { remaining = summary.Platform.CreditsRemaining } } return &domain.CostPreview{ PreviewID: domain.NewID(), Action: action, KeyMode: mode, FixedCredits: fixed, MinCredits: min, MaxCredits: max, SearchCalls: searchCalls, MaxAICandidates: maxAI, RemainingCredits: remaining, EstimateBasis: "免費前處理;provider 成功後依 web_search/ai_research/ai_copy meter 計點", ExpiresAt: time.Now().UTC().Add(5 * time.Minute).UnixNano(), }, nil }