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

134 lines
4.6 KiB
Go
Raw Normal View History

2026-08-09 07:57:35 +00:00
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"
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) {
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)
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 {
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)),
}
res, _, jerr := s.JudgeCandidate(ctx, ownerUID, profile, nil, 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,
}
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 != ""
}