2026-08-09 07:57:35 +00:00
|
|
|
|
package usecase
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
|
|
"apps/backend/internal/module/radar/domain"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// ExploreResult is the outcome of an on-demand explore run (no watch subscription).
|
|
|
|
|
|
type ExploreResult struct {
|
|
|
|
|
|
SweepID string
|
|
|
|
|
|
HitCount int
|
|
|
|
|
|
JudgedCount int
|
|
|
|
|
|
CreatedCount int
|
2026-08-13 02:22:24 +00:00
|
|
|
|
MatchedCount int
|
|
|
|
|
|
MergedCount int
|
2026-08-09 07:57:35 +00:00
|
|
|
|
TruncatedCount int
|
|
|
|
|
|
CreditsUsed int
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
ExploreOpportunities runs an immediate search → five-question judge pipeline
|
|
|
|
|
|
for a short list of Threads-searchable terms (contract B / plan T3).
|
|
|
|
|
|
|
|
|
|
|
|
Reuses FetchCandidates path (HitFetch fan-out) and ProcessCandidates (quota,
|
|
|
|
|
|
dedupe, reasons). Does not bypass daily opportunity caps. Not async.
|
|
|
|
|
|
*/
|
|
|
|
|
|
func (s *Service) ExploreOpportunities(ctx context.Context, ownerUID int64, rawTerms []string) (*ExploreResult, error) {
|
2026-08-13 02:22:24 +00:00
|
|
|
|
return s.exploreOpportunities(ctx, ownerUID, rawTerms, "", "")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Service) ExploreProductOpportunities(ctx context.Context, ownerUID int64, rawTerms []string, brandID, productID string) (*ExploreResult, error) {
|
|
|
|
|
|
brandID, productID = strings.TrimSpace(brandID), strings.TrimSpace(productID)
|
|
|
|
|
|
if (brandID == "") != (productID == "") {
|
|
|
|
|
|
return nil, fmt.Errorf("%w: brand_id and product_id must be provided together", domain.ErrValidation)
|
|
|
|
|
|
}
|
|
|
|
|
|
if brandID == "" {
|
|
|
|
|
|
return s.exploreOpportunities(ctx, ownerUID, rawTerms, "", "")
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, err := s.LoadProductContext(ctx, ownerUID, brandID, productID); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.exploreOpportunities(ctx, ownerUID, rawTerms, brandID, productID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Service) exploreOpportunities(ctx context.Context, ownerUID int64, rawTerms []string, brandID, productID string) (*ExploreResult, error) {
|
2026-08-09 07:57:35 +00:00
|
|
|
|
if ownerUID <= 0 {
|
|
|
|
|
|
return nil, fmt.Errorf("%w: owner_uid required", domain.ErrValidation)
|
|
|
|
|
|
}
|
|
|
|
|
|
terms, err := validateExploreTerms(rawTerms)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Profile optional at gate — judge tolerates nil profile (same as manual import).
|
|
|
|
|
|
profile, _ := s.Repo.GetServiceProfile(ctx, ownerUID)
|
|
|
|
|
|
|
|
|
|
|
|
// Synthetic watch so FetchCandidates / ProcessCandidates can carry terms.
|
|
|
|
|
|
w := &domain.RadarWatch{
|
|
|
|
|
|
ID: "",
|
|
|
|
|
|
OwnerUID: ownerUID,
|
|
|
|
|
|
Terms: terms,
|
|
|
|
|
|
Status: domain.WatchActive,
|
|
|
|
|
|
}
|
2026-08-13 02:22:24 +00:00
|
|
|
|
var productContext *ProductContextSnapshot
|
|
|
|
|
|
if brandID != "" {
|
|
|
|
|
|
product, err := s.LoadProductContext(ctx, ownerUID, brandID, productID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
productContext = product
|
|
|
|
|
|
w.ContextMode, w.BrandID, w.ProductID = domain.WatchContextProduct, brandID, productID
|
|
|
|
|
|
w.BrandNameSnapshot, w.ProductLabelSnapshot = product.BrandName, product.ProductLabel
|
|
|
|
|
|
}
|
|
|
|
|
|
if productContext != nil {
|
|
|
|
|
|
if dm, derr := s.GetDemandMap(ctx, ownerUID, productID); derr == nil {
|
|
|
|
|
|
if plan, perr := BuildQueryPlan(dm, productContext.ProductLabel); perr == nil && plan != nil {
|
2026-08-13 07:54:25 +00:00
|
|
|
|
w = mergeFetchWatch(w, plan)
|
2026-08-13 02:22:24 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-09 07:57:35 +00:00
|
|
|
|
|
|
|
|
|
|
// Cap hits for sync explore (≤20); AI judge still subject to daily quota inside ProcessCandidates.
|
|
|
|
|
|
const exploreHitLimit = 20
|
|
|
|
|
|
cands, path, fetchCredits, ferr := s.FetchCandidates(ctx, ownerUID, w, exploreHitLimit)
|
|
|
|
|
|
if ferr != nil {
|
|
|
|
|
|
return nil, ferr
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
sw, err := s.BeginSweepRecord(ctx, ownerUID, "", "", path)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if path != "" {
|
|
|
|
|
|
_ = s.setSweepPath(ctx, sw.ID, path)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 02:22:24 +00:00
|
|
|
|
rawHitCount := len(cands)
|
|
|
|
|
|
cands, prefilter := PrefilterCandidates(cands, w, productContext)
|
2026-08-09 07:57:35 +00:00
|
|
|
|
_, _ = s.Repo.UpdateSweep(ctx, sw.ID, domain.SweepDelta{
|
2026-08-13 02:22:24 +00:00
|
|
|
|
HitCount: rawHitCount,
|
|
|
|
|
|
CreditsUsed: fetchCredits,
|
|
|
|
|
|
CreditSearch: fetchCredits,
|
|
|
|
|
|
DedupedCount: prefilter.Deduped, PrefilterPassCount: prefilter.Pass,
|
|
|
|
|
|
PrefilterReviewCount: prefilter.Review, PrefilterRejectedCount: prefilter.Rejected,
|
2026-08-09 07:57:35 +00:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-08-13 07:54:25 +00:00
|
|
|
|
created, _, judged, truncated, failed, judgeCredits, perr := s.ProcessCandidates(
|
2026-08-09 07:57:35 +00:00
|
|
|
|
ctx, ownerUID, w, profile, sw.ID, cands, nil,
|
|
|
|
|
|
)
|
|
|
|
|
|
if perr != nil {
|
|
|
|
|
|
reason := "判定流程失敗:" + perr.Error()
|
|
|
|
|
|
end := domain.NowNano()
|
|
|
|
|
|
_, _ = s.Repo.UpdateSweep(ctx, sw.ID, domain.SweepDelta{FailedReason: &reason, EndedAt: end})
|
|
|
|
|
|
return nil, perr
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
end := domain.NowNano()
|
|
|
|
|
|
var failPtr *string
|
|
|
|
|
|
if failed > 0 && created == 0 && judged > 0 {
|
|
|
|
|
|
r := fmt.Sprintf("%d 筆判定失敗", failed)
|
|
|
|
|
|
failPtr = &r
|
|
|
|
|
|
}
|
|
|
|
|
|
sw, _ = s.Repo.UpdateSweep(ctx, sw.ID, domain.SweepDelta{
|
|
|
|
|
|
CreditsUsed: judgeCredits,
|
2026-08-13 02:22:24 +00:00
|
|
|
|
CreditJudge: judgeCredits,
|
2026-08-09 07:57:35 +00:00
|
|
|
|
EndedAt: end,
|
|
|
|
|
|
FailedReason: failPtr,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
credits := fetchCredits + judgeCredits
|
|
|
|
|
|
if sw != nil {
|
|
|
|
|
|
// UpdateSweep may have accumulated credits differently; prefer stored total when available.
|
|
|
|
|
|
if sw.CreditsUsed > 0 {
|
|
|
|
|
|
credits = sw.CreditsUsed
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return &ExploreResult{
|
|
|
|
|
|
SweepID: sw.ID,
|
|
|
|
|
|
HitCount: len(cands),
|
|
|
|
|
|
JudgedCount: judged,
|
|
|
|
|
|
CreatedCount: created,
|
2026-08-13 02:22:24 +00:00
|
|
|
|
MatchedCount: sw.MatchEvaluatedCount,
|
|
|
|
|
|
MergedCount: sw.MatchMergedCount,
|
2026-08-09 07:57:35 +00:00
|
|
|
|
TruncatedCount: truncated,
|
|
|
|
|
|
CreditsUsed: credits,
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func validateExploreTerms(raw []string) ([]string, error) {
|
|
|
|
|
|
if len(raw) == 0 {
|
|
|
|
|
|
return nil, fmt.Errorf("%w: terms required (1–%d)", domain.ErrValidation, domain.MaxExploreTerms)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(raw) > domain.MaxExploreTerms {
|
|
|
|
|
|
return nil, fmt.Errorf("%w: at most %d terms", domain.ErrValidation, domain.MaxExploreTerms)
|
|
|
|
|
|
}
|
|
|
|
|
|
out := make([]string, 0, len(raw))
|
|
|
|
|
|
seen := map[string]bool{}
|
|
|
|
|
|
for _, r := range raw {
|
|
|
|
|
|
term := domain.NormalizeSearchTerm(r)
|
|
|
|
|
|
if term == "" {
|
|
|
|
|
|
return nil, fmt.Errorf("%w: empty term", domain.ErrValidation)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !domain.IsThreadsSearchable(term) {
|
|
|
|
|
|
return nil, fmt.Errorf("%w: term %q is not Threads-searchable (≤2 tokens, CJK 2–4 chars, ≤12 total, no punctuation)", domain.ErrValidation, term)
|
|
|
|
|
|
}
|
|
|
|
|
|
key := strings.ToLower(term)
|
|
|
|
|
|
if seen[key] {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
seen[key] = true
|
|
|
|
|
|
out = append(out, term)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(out) == 0 {
|
|
|
|
|
|
return nil, fmt.Errorf("%w: terms required", domain.ErrValidation)
|
|
|
|
|
|
}
|
|
|
|
|
|
return out, nil
|
|
|
|
|
|
}
|