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"
|
2026-08-13 02:22:24 +00:00
|
|
|
|
ImportStatusMerged = "merged"
|
2026-08-09 07:57:35 +00:00
|
|
|
|
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) {
|
2026-08-13 02:22:24 +00:00
|
|
|
|
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) {
|
2026-08-09 07:57:35 +00:00
|
|
|
|
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)
|
2026-08-13 02:22:24 +00:00
|
|
|
|
var product *ProductContextSnapshot
|
|
|
|
|
|
if brandID != "" {
|
|
|
|
|
|
var err error
|
|
|
|
|
|
product, err = s.LoadProductContext(ctx, ownerUID, brandID, productID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-09 07:57:35 +00:00
|
|
|
|
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 {
|
2026-08-13 02:22:24 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-08-09 07:57:35 +00:00
|
|
|
|
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)),
|
|
|
|
|
|
}
|
2026-08-13 02:22:24 +00:00
|
|
|
|
var judgeWatch *domain.RadarWatch
|
|
|
|
|
|
if product != nil {
|
|
|
|
|
|
judgeWatch = &domain.RadarWatch{ContextMode: domain.WatchContextProduct, BrandID: product.BrandID, ProductID: product.ProductID, ID: "manual-import"}
|
|
|
|
|
|
}
|
2026-08-09 07:57:35 +00:00
|
|
|
|
|
2026-08-13 02:22:24 +00:00
|
|
|
|
res, _, jerr := s.JudgeCandidate(ctx, ownerUID, profile, judgeWatch, cand)
|
2026-08-09 07:57:35 +00:00
|
|
|
|
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,
|
|
|
|
|
|
}
|
2026-08-13 02:22:24 +00:00
|
|
|
|
if res.ProductMatch != nil {
|
|
|
|
|
|
res.ProductMatch.MatchedTerms = []string{"manual_import"}
|
|
|
|
|
|
o.ProductMatches = []*domain.ProductMatch{res.ProductMatch}
|
|
|
|
|
|
domain.ApplyPrimaryProduct(o)
|
|
|
|
|
|
}
|
2026-08-09 07:57:35 +00:00
|
|
|
|
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 != ""
|
|
|
|
|
|
}
|