thread-master/apps/backend/internal/module/radar/usecase/manual_import.go

184 lines
7.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 讓使用者貼 ThreadsFacebook 貼文網址(或 CSV 批次貼上多筆)
建立商機,跑同一套五問判定,來源標 manual_importspec §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 != ""
}