package usecase import ( "context" "fmt" "net/url" "strings" "apps/backend/internal/module/radar/domain" ) // MaxManualImportBatch caps one CSV/paste batch so a single request can't force // an unbounded number of AI judge calls (spec §4.11 P1: 手動匯入不繞過五問判定成本). const MaxManualImportBatch = 20 // ManualImportItem is one row from a pasted URL or CSV batch import. type ManualImportItem struct { URL string Text string Author string PostedAt int64 // unix ns; <=0 => now (import time) } // ManualImportResult reports the outcome of one row so the caller can show a // per-row status even when some rows in the batch fail or are duplicates. type ManualImportResult struct { URL string OpportunityID string Status string // qualified | rejected | skipped | failed IntentBand string IntentScore int Error string } const ( ImportStatusSkipped = "skipped" ImportStatusMerged = "merged" ImportStatusFailed = "failed" ) /* ImportManualOpportunities 讓使用者貼 Threads/Facebook 貼文網址(或 CSV 批次貼上多筆) 建立商機,跑同一套五問判定,來源標 manual_import(spec §4.11 P1)。 不吃每日商機上限:這是使用者主動指定的單筆,不是自動巡的噪音,不該被截斷邏輯擋掉。 同一 owner 下同網址已匯入過 → 略過不重判(跟 sweep 的 dedupe 邏輯一致)。 */ func (s *Service) ImportManualOpportunities(ctx context.Context, ownerUID int64, items []ManualImportItem) ([]ManualImportResult, error) { return s.importManualOpportunities(ctx, ownerUID, items, "", "") } // ImportManualProductOpportunities runs the same manual-import path with an // owned Brand/Product context and attaches ProductMatch evidence. func (s *Service) ImportManualProductOpportunities(ctx context.Context, ownerUID int64, items []ManualImportItem, brandID, productID string) ([]ManualImportResult, 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) } return s.importManualOpportunities(ctx, ownerUID, items, brandID, productID) } func (s *Service) importManualOpportunities(ctx context.Context, ownerUID int64, items []ManualImportItem, brandID, productID string) ([]ManualImportResult, error) { if ownerUID <= 0 { return nil, fmt.Errorf("%w: owner required", domain.ErrValidation) } if len(items) == 0 { return nil, fmt.Errorf("%w: at least one item required", domain.ErrValidation) } if len(items) > MaxManualImportBatch { return nil, fmt.Errorf("%w: at most %d rows per import", domain.ErrValidation, MaxManualImportBatch) } profile, _ := s.Repo.GetServiceProfile(ctx, ownerUID) var product *ProductContextSnapshot if brandID != "" { var err error product, err = s.LoadProductContext(ctx, ownerUID, brandID, productID) if err != nil { return nil, err } } now := domain.NowNano() results := make([]ManualImportResult, 0, len(items)) for _, item := range items { rawURL := strings.TrimSpace(item.URL) text := strings.TrimSpace(item.Text) if rawURL == "" || text == "" { results = append(results, ManualImportResult{ URL: rawURL, Status: ImportStatusFailed, Error: "網址與貼文內文皆必填", }) continue } if !isImportableURL(rawURL) { results = append(results, ManualImportResult{ URL: rawURL, Status: ImportStatusFailed, Error: "網址格式不正確,需為 http(s) 開頭", }) continue } if existing, gerr := s.Repo.GetByExternalID(ctx, ownerUID, rawURL); gerr == nil && existing != nil { if product != nil && productMatchFor(existing, product.ProductID) == nil { postedAt := item.PostedAt if postedAt <= 0 { postedAt = now } cand := &domain.CandidatePost{ExternalID: rawURL, Permalink: rawURL, AuthorHandle: item.Author, Text: text, PostedAt: postedAt, MatchedTerm: "manual_import", Classification: classifyCandidate(strings.ToLower(text))} match, _, merr := s.scoreProductFit(ctx, ownerUID, product, cand) if merr == nil { match.MatchedTerms = []string{"manual_import"} _, merr = s.Repo.MergeProductMatch(ctx, ownerUID, existing.ID, match) } if merr != nil { results = append(results, ManualImportResult{URL: rawURL, OpportunityID: existing.ID, Status: ImportStatusFailed, Error: "產品匹配合併失敗,請稍後再試"}) } else { results = append(results, ManualImportResult{URL: rawURL, OpportunityID: existing.ID, Status: ImportStatusMerged, IntentBand: existing.IntentBand, IntentScore: existing.IntentScore, Error: "已補上產品匹配,未重建商機"}) } continue } results = append(results, ManualImportResult{ URL: rawURL, OpportunityID: existing.ID, Status: ImportStatusSkipped, IntentBand: existing.IntentBand, IntentScore: existing.IntentScore, Error: "這個網址已經匯入過,略過重複判定", }) continue } postedAt := item.PostedAt if postedAt <= 0 { postedAt = now } author := strings.TrimSpace(item.Author) if author == "" { author = authorFromURL(rawURL) } cand := &domain.CandidatePost{ ExternalID: rawURL, Permalink: rawURL, AuthorHandle: author, Text: text, PostedAt: postedAt, MatchedTerm: "manual_import", Classification: classifyCandidate(strings.ToLower(text)), } var judgeWatch *domain.RadarWatch if product != nil { judgeWatch = &domain.RadarWatch{ContextMode: domain.WatchContextProduct, BrandID: product.BrandID, ProductID: product.ProductID, ID: "manual-import"} } res, _, jerr := s.JudgeCandidate(ctx, ownerUID, profile, judgeWatch, cand) if jerr != nil || res == nil { results = append(results, ManualImportResult{URL: rawURL, Status: ImportStatusFailed, Error: "判定失敗,請稍後再試"}) continue } o := &domain.Opportunity{ ID: domain.NewID(), OwnerUID: ownerUID, Source: domain.OppSourceManualImport, ExternalID: rawURL, Permalink: rawURL, AuthorHandle: author, Text: text, PostedAt: postedAt, Status: res.Status, IntentScore: res.IntentScore, IntentBand: res.IntentBand, Reasons: res.Reasons, RegionDetected: res.RegionDetected, RegionMatch: res.RegionMatch, FreshnessHours: res.FreshnessHours, MatchedService: res.MatchedService, MatchedTerms: []string{"manual_import"}, RejectReason: res.RejectReason, } if res.ProductMatch != nil { res.ProductMatch.MatchedTerms = []string{"manual_import"} o.ProductMatches = []*domain.ProductMatch{res.ProductMatch} domain.ApplyPrimaryProduct(o) } saved, perr := s.Repo.UpsertByExternalID(ctx, o) if perr != nil { results = append(results, ManualImportResult{URL: rawURL, Status: ImportStatusFailed, Error: "儲存失敗,請稍後再試"}) continue } results = append(results, ManualImportResult{ URL: rawURL, OpportunityID: saved.ID, Status: saved.Status, IntentBand: saved.IntentBand, IntentScore: saved.IntentScore, }) } return results, nil } func isImportableURL(raw string) bool { u, err := url.Parse(raw) if err != nil { return false } return (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" }