75 lines
2.5 KiB
Go
75 lines
2.5 KiB
Go
|
|
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
|
|||
|
|
}
|