fix all bug

This commit is contained in:
王性驊 2026-08-13 07:54:25 +00:00
parent 806cd51333
commit ee527ed988
62 changed files with 2291 additions and 393 deletions

View File

@ -408,12 +408,17 @@ func runRadarSweep(ctx context.Context, jobs *jobUC.Service, radar *radarUC.Serv
} }
res, err := radar.RunSweep(ctx, j.OwnerUID, watchID, j.ID) res, err := radar.RunSweep(ctx, j.OwnerUID, watchID, j.ID)
if err != nil { if err != nil {
// RunSweep persists a user-facing reason on fetch failures. Return that
// instead of leaking the crawler/provider transport response into Jobs UI.
if res != nil && res.FetchFailed && strings.TrimSpace(res.FailedReason) != "" {
return errors.New(res.FailedReason)
}
return err return err
} }
summary := fmt.Sprintf("雷達巡檢完成 · 新建 %d · 判定 %d · 截斷 %d", res.Created, res.Judged, res.Truncated)
if res.FetchFailed { if res.FetchFailed {
return fmt.Errorf("%s", res.FailedReason) return fmt.Errorf("%s", res.FailedReason)
} }
summary := fmt.Sprintf("雷達巡檢完成 · 新建 %d · 再次命中 %d · 判定 %d · 截斷 %d", res.Created, res.Rematched, res.Judged, res.Truncated)
if _, err := jobs.MarkRunningProgress(ctx, j.ID, 90, summary); err != nil { if _, err := jobs.MarkRunningProgress(ctx, j.ID, 90, summary); err != nil {
return err return err
} }

View File

@ -31,7 +31,7 @@ func (l *ListOpportunitiesLogic) ListOpportunities(req *types.ListOpportunitiesR
postedFrom, postedTo := req.From, req.To postedFrom, postedTo := req.From, req.To
now := domain.NowNano() now := domain.NowNano()
if req.TimeScope == "today" { if req.TimeScope == "today" {
postedFrom, postedTo = domain.UTCDayBounds(now) postedFrom, postedTo = domain.LocalDayBounds(now, domain.DisplayLocation())
} else if req.TimeScope == "7d" { } else if req.TimeScope == "7d" {
postedFrom, postedTo = now-7*int64(24*time.Hour), 0 postedFrom, postedTo = now-7*int64(24*time.Hour), 0
} }

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -227,6 +228,61 @@ func (s *Service) findRadarSweepForRef(ctx context.Context, ownerUID int64, ref
return nil, nil return nil, nil
} }
// ScheduleManualRadarSweep starts an extra patrol now.
// Daily jobs are keyed watch+UTC day and must stay idempotent; a finished
// daily job must not swallow 「立即巡邏」. If this watch already has a
// queued/running sweep, that in-flight job is returned instead of stacking.
func (s *Service) ScheduleManualRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (*domain.Job, error) {
watchID = strings.TrimSpace(watchID)
if ownerUID <= 0 || watchID == "" {
return nil, domain.ErrForbidden
}
if runAt <= 0 {
runAt = domain.NowNano()
}
list, err := s.Repo.ListByOwner(ctx, ownerUID)
if err != nil {
return nil, err
}
prefix := watchID + ":"
for _, j := range list {
if j == nil || j.TemplateType != domain.TemplateRadarSweep {
continue
}
if !strings.HasPrefix(j.RefID, prefix) {
continue
}
if !domain.IsTerminal(j.Status) {
return j, nil
}
}
day := time.Unix(0, runAt).UTC().Format("2006-01-02")
ref := watchID + ":manual:" + strconv.FormatInt(runAt, 10)
body, err := json.Marshal(RadarSweepPayload{WatchID: watchID, Day: day})
if err != nil {
return nil, err
}
now := domain.NowNano()
j := &domain.Job{
ID: uuid.NewString(),
OwnerUID: ownerUID,
TemplateType: domain.TemplateRadarSweep,
Status: domain.StatusQueued,
RefID: ref,
Payload: string(body),
RunAfter: runAt,
ProgressSummary: "立即巡邏已排程 · 等待 worker",
ProgressPercent: 0,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.Repo.Insert(ctx, j); err != nil {
return nil, err
}
s.notify(ctx, j)
return j, nil
}
func (s *Service) List(ctx context.Context, ownerUID int64) ([]*domain.Job, error) { func (s *Service) List(ctx context.Context, ownerUID int64) ([]*domain.Job, error) {
return s.Repo.ListByOwner(ctx, ownerUID) return s.Repo.ListByOwner(ctx, ownerUID)
} }

View File

@ -374,6 +374,32 @@ func TestJobLease_HeartbeatRenewsWhileRunning(t *testing.T) {
}, 200*time.Millisecond, 5*time.Millisecond) }, 200*time.Millisecond, 5*time.Millisecond)
} }
func TestScheduleManualRadarSweep_ReusesInFlightAndCreatesAfterSuccess(t *testing.T) {
ctx := context.Background()
svc := usecase.New(jobRepo.NewMemory())
first, err := svc.ScheduleManualRadarSweep(ctx, 42, "watch-1", 1)
require.NoError(t, err)
require.Contains(t, first.RefID, ":manual:")
again, err := svc.ScheduleManualRadarSweep(ctx, 42, "watch-1", 2)
require.NoError(t, err)
require.Equal(t, first.ID, again.ID)
claimed, err := svc.ClaimNext(ctx, "worker-manual")
require.NoError(t, err)
require.Equal(t, first.ID, claimed.ID)
_, err = svc.SucceedJob(ctx, first.ID, "done")
require.NoError(t, err)
second, err := svc.ScheduleManualRadarSweep(ctx, 42, "watch-1", 3)
require.NoError(t, err)
require.NotEqual(t, first.ID, second.ID)
require.Contains(t, second.RefID, ":manual:")
daily, err := svc.ScheduleRadarSweep(ctx, 42, "watch-1", 1)
require.NoError(t, err)
require.NotEqual(t, second.ID, daily.ID)
require.NotContains(t, daily.RefID, ":manual:")
}
func TestJobLease_ReclaimsLegacyRunningDocumentWithoutLease(t *testing.T) { func TestJobLease_ReclaimsLegacyRunningDocumentWithoutLease(t *testing.T) {
repo := jobRepo.NewMemory() repo := jobRepo.NewMemory()
now := domain.NowNano() now := domain.NowNano()

View File

@ -431,3 +431,51 @@ func UTCDayBounds(at int64) (start, end int64) {
day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
return day.UnixNano(), day.Add(24 * time.Hour).UnixNano() return day.UnixNano(), day.Add(24 * time.Hour).UnixNano()
} }
// DisplayLocation is the product default timezone (Harbor Desk is Taipei-first).
func DisplayLocation() *time.Location {
return time.FixedZone("Asia/Taipei", 8*60*60)
}
// LocalDayBounds returns [start, end) unix ns for the local calendar day that contains at.
func LocalDayBounds(at int64, loc *time.Location) (start, end int64) {
if loc == nil {
loc = DisplayLocation()
}
if at <= 0 {
at = NowNano()
}
t := time.Unix(0, at).In(loc)
day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
return day.UnixNano(), day.Add(24 * time.Hour).UnixNano()
}
// InInboxTimeScope is true when a row belongs on today/7d.
// Posted-in-range is the primary meaning; a just-finished sweep must still
// surface older or undated posts via last_matched_at / created_at, otherwise
// the default pending+today inbox looks empty after patrol.
func InInboxTimeScope(postedAt, lastMatchedAt, createdAt, from, to int64) bool {
if from <= 0 && to <= 0 {
return true
}
in := func(ts int64) bool {
if ts <= 0 {
return false
}
if from > 0 && ts < from {
return false
}
if to > 0 && ts >= to {
return false
}
return true
}
if in(postedAt) {
return true
}
discovered := lastMatchedAt
if discovered <= 0 {
discovered = createdAt
}
return in(discovered)
}

View File

@ -3,6 +3,7 @@ package domain
import ( import (
"errors" "errors"
"testing" "testing"
"time"
) )
func TestBandFromScore_LockedThresholds(t *testing.T) { func TestBandFromScore_LockedThresholds(t *testing.T) {
@ -34,6 +35,18 @@ func TestUnknownPublishedTimeIsNotStale(t *testing.T) {
} }
} }
func TestTwentyDayOldPostIsNotHardRejected(t *testing.T) {
now := NowNano()
posted := now - 20*24*int64(time.Hour)
if IsStaleHardReject(posted, now) {
t.Fatal("a 20-day-old demand post must still be judgeable, not hard-rejected")
}
old := now - 40*24*int64(time.Hour)
if !IsStaleHardReject(old, now) {
t.Fatal("a 40-day-old post should still be hard-rejected")
}
}
func TestValidateReasons_RequiresAllFive(t *testing.T) { func TestValidateReasons_RequiresAllFive(t *testing.T) {
full := fiveReasons() full := fiveReasons()
if err := ValidateReasons(full); err != nil { if err := ValidateReasons(full); err != nil {

View File

@ -0,0 +1,43 @@
package domain
import (
"testing"
"time"
)
func TestLocalDayBoundsUsesTaipeiNotUTC(t *testing.T) {
// 2026-08-13 02:44 UTC is 10:44 in Taipei, same local calendar day.
// 2026-08-13 00:30 Taipei is still 2026-08-12 16:30 UTC.
morningTaipei := time.Date(2026, 8, 13, 0, 30, 0, 0, DisplayLocation()).UnixNano()
start, end := LocalDayBounds(morningTaipei, DisplayLocation())
utcStart, utcEnd := UTCDayBounds(morningTaipei)
if start == utcStart {
t.Fatalf("local day must not collapse to UTC day: local=%d utc=%d", start, utcStart)
}
if morningTaipei < start || morningTaipei >= end {
t.Fatalf("Taipei 00:30 should sit inside local today [%d, %d)", start, end)
}
if utcEnd <= start {
t.Fatal("expected UTC Aug 12 window to end at Taipei midnight Aug 13")
}
}
func TestInInboxTimeScopeKeepsResweptAndUndatedRows(t *testing.T) {
from, to := LocalDayBounds(NowNano(), DisplayLocation())
oldPost := from - int64(48*time.Hour)
if InInboxTimeScope(oldPost, 0, oldPost, from, to) {
t.Fatal("stale post that was not rematched today should stay out of today")
}
if !InInboxTimeScope(oldPost, from+1, oldPost, from, to) {
t.Fatal("a post rematched today must remain visible after patrol")
}
if !InInboxTimeScope(0, 0, from+1, from, to) {
t.Fatal("undated post created today must remain visible after patrol")
}
if !InInboxTimeScope(oldPost, 0, from+1, from, to) {
t.Fatal("older post created today without last_matched must remain visible (no-backfill)")
}
if !InInboxTimeScope(from+1, 0, oldPost, from, to) {
t.Fatal("post published today should stay on today even if created earlier")
}
}

View File

@ -11,8 +11,10 @@ const (
WeightFit = 10 WeightFit = 10
) )
// Freshness hard-reject after 14 days. // Freshness scoring decays after 14 days. Hard-reject is wider so Exa/Threads
// hits from the last month still reach the inbox as stale, not as "found nothing".
const MaxFreshnessDays = 14 const MaxFreshnessDays = 14
const StaleHardRejectDays = 30
// FreshnessScore maps age in hours to the 015 freshness dimension score. // FreshnessScore maps age in hours to the 015 freshness dimension score.
func FreshnessScore(hours int) int { func FreshnessScore(hours int) int {
@ -50,12 +52,14 @@ func FreshnessHoursSince(postedAt, now int64) int {
return h return h
} }
// IsStaleHardReject is true when the post is older than 14 days. // IsStaleHardReject is true when the post is older than a month.
// 1430 day posts stay scoreable (low freshness) so a just-run patrol can
// still surface a real pain that Threads/Exa returned as an older result.
func IsStaleHardReject(postedAt, now int64) bool { func IsStaleHardReject(postedAt, now int64) bool {
if postedAt <= 0 { if postedAt <= 0 {
return false return false
} }
return FreshnessHoursSince(postedAt, now) > MaxFreshnessDays*24 return FreshnessHoursSince(postedAt, now) > StaleHardRejectDays*24
} }
// SumReasonScores totals dimension scores (capped components assumed already). // SumReasonScores totals dimension scores (capped components assumed already).

View File

@ -52,8 +52,8 @@ func NormalizeSuggestUsage(s string) string {
CleanSuggestions 收掉空白與重複丟掉沒有理由的項目並套用數量上限 CleanSuggestions 收掉空白與重複丟掉沒有理由的項目並套用數量上限
沒有理由的項目直接丟補一句AI 建議等於假裝有理由比少一則更糟 沒有理由的項目直接丟補一句AI 建議等於假裝有理由比少一則更糟
include 關鍵字必須通過 Threads 短詞規則IsThreadsSearchable不合規整條丟掉不截短 include 必須能在 Threads 搜到已合規的原詞保留過長或超過兩個 token 的先收成短詞變體
避免產出半截怪詞exclude 仍用較寬的長度界線訂閱排除詞可能較長 變體也沒有才丟掉exclude 仍用較寬的長度界線訂閱排除詞可能較長
*/ */
func CleanSuggestions(in []WatchTermSuggestion, limit int) []WatchTermSuggestion { func CleanSuggestions(in []WatchTermSuggestion, limit int) []WatchTermSuggestion {
if limit <= 0 || limit > MaxSuggestions { if limit <= 0 || limit > MaxSuggestions {
@ -68,9 +68,17 @@ func CleanSuggestions(in []WatchTermSuggestion, limit int) []WatchTermSuggestion
continue continue
} }
usage := NormalizeSuggestUsage(s.Usage) usage := NormalizeSuggestUsage(s.Usage)
basisText := strings.TrimSpace(s.BasisText)
if usage == SuggestUsageInclude { if usage == SuggestUsageInclude {
if !IsThreadsSearchable(term) { if !IsThreadsSearchable(term) {
continue variants := SearchableTermVariants(term, true)
if len(variants) == 0 {
continue
}
if basisText == "" {
basisText = term
}
term = variants[0]
} }
} else { } else {
// exclude: keep broader length; still reject empty after normalize // exclude: keep broader length; still reject empty after normalize
@ -86,7 +94,7 @@ func CleanSuggestions(in []WatchTermSuggestion, limit int) []WatchTermSuggestion
seen[key] = true seen[key] = true
out = append(out, WatchTermSuggestion{ out = append(out, WatchTermSuggestion{
Term: term, Reason: reason, Usage: usage, Term: term, Reason: reason, Usage: usage,
BasisKind: strings.TrimSpace(s.BasisKind), BasisText: strings.TrimSpace(s.BasisText), BasisKind: strings.TrimSpace(s.BasisKind), BasisText: basisText,
}) })
if len(out) >= limit { if len(out) >= limit {
break break

View File

@ -9,11 +9,14 @@ import (
// Threads 搜尋短詞硬約束(中文斷詞差、長字串常查無結果)。 // Threads 搜尋短詞硬約束(中文斷詞差、長字串常查無結果)。
// 一組查詢 = 最多 2 個 token半形空格分隔中文 token 24 字;整組去掉空格後 ≤12 字元。 // 一組查詢 = 最多 2 個 token半形空格分隔中文 token 24 字;整組去掉空格後 ≤12 字元。
const ( const (
MaxThreadsTokens = 2 MaxThreadsTokens = 2
MinCJKTokenRunes = 2 MinCJKTokenRunes = 2
MaxCJKTokenRunes = 4 MaxCJKTokenRunes = 4
MaxThreadsTermRunes = 12 // 去掉空白後 MaxThreadsTermRunes = 12 // 去掉空白後
MaxExploreTerms = 6 MaxExploreTerms = 6
// 一則長句最多收成幾組可搜短詞,避免滑窗把訂閱或需求地圖塞滿半截字。
MaxSearchableVariantsPerTerm = 3
MaxSearchableVariants = 8
) )
// NormalizeSearchTerm trims, converts full-width spaces to half-width, and collapses whitespace. // NormalizeSearchTerm trims, converts full-width spaces to half-width, and collapses whitespace.
@ -109,3 +112,180 @@ func isCJK(r rune) bool {
unicode.Is(unicode.Katakana, r) || unicode.Is(unicode.Katakana, r) ||
(r >= 0x3000 && r <= 0x303F) // CJK punctuation block — treated as CJK char class but punct rejected above (r >= 0x3000 && r <= 0x303F) // CJK punctuation block — treated as CJK char class but punct rejected above
} }
var searchFillers = []string{
"怎麼辦", "求推薦", "有沒有人", "有人知道", "請問一下", "請問",
"想問", "想找", "有沒有", "可以嗎", "好不好",
}
// SearchableTermVariants turns a user/product phrase into Threads-searchable queries.
// If the input already passes IsThreadsSearchable it is the only result.
// Longer phrases are shortened: adjacent token pairs, filler tails stripped,
// then first/last 24 CJK windows. include=false keeps the broader exclude length.
func SearchableTermVariants(raw string, include bool) []string {
raw = NormalizeSearchTerm(raw)
if raw == "" {
return nil
}
seen := map[string]bool{}
out := make([]string, 0, MaxSearchableVariants)
add := func(term string) {
term = NormalizeSearchTerm(term)
if term == "" || seen[strings.ToLower(term)] {
return
}
if include {
if !IsThreadsSearchable(term) {
return
}
} else if n := utf8.RuneCountInString(term); n < MinTermLen || n > MaxTermLen {
return
}
seen[strings.ToLower(term)] = true
out = append(out, term)
}
if include && IsThreadsSearchable(raw) {
add(raw)
return out
}
if !include {
add(raw)
if len(out) > 0 {
return out
}
}
tokens := strings.Fields(raw)
if len(tokens) > MaxThreadsTokens {
for i := 0; i+1 < len(tokens) && len(out) < MaxSearchableVariants; i++ {
add(tokens[i] + " " + tokens[i+1])
}
for _, tok := range tokens {
if len(out) >= MaxSearchableVariants {
break
}
for _, v := range SearchableTermVariants(tok, include) {
add(v)
if len(out) >= MaxSearchableVariants {
break
}
}
}
return out
}
parts := splitSearchParts(raw)
if len(parts) == 0 {
parts = []string{raw}
}
for _, part := range parts {
if len(out) >= MaxSearchableVariants {
break
}
if include && IsThreadsSearchable(part) {
add(part)
continue
}
stripped := stripSearchFillers(part)
if stripped != "" && stripped != part {
add(stripped)
}
runes := []rune(stripped)
if len(runes) == 0 {
runes = []rune(part)
}
if len(runes) == 5 {
add(string(runes[:3]))
add(string(runes[3:]))
}
for _, width := range []int{4, 3, 2} {
if len(runes) < width {
continue
}
add(string(runes[:width]))
if len(runes) > width {
add(string(runes[len(runes)-width:]))
}
}
if len(out) >= 3 {
continue
}
for width := 4; width >= 2; width-- {
if len(runes) < width {
continue
}
for start := 0; start+width <= len(runes) && len(out) < MaxSearchableVariants; start++ {
add(string(runes[start : start+width]))
}
}
}
return out
}
// ExpandSearchTerms keeps already-searchable include terms and shortens the rest.
// Used when persisting a watch and when fanning out a sweep.
func ExpandSearchTerms(terms []string, max int) []string {
if max <= 0 {
max = MaxWatchTerms
}
out := make([]string, 0, max)
seen := map[string]bool{}
add := func(term string) bool {
term = strings.ToLower(NormalizeSearchTerm(term))
if term == "" || !IsThreadsSearchable(term) || seen[term] {
return len(out) < max
}
seen[term] = true
out = append(out, term)
return len(out) < max
}
for _, raw := range terms {
raw = NormalizeSearchTerm(raw)
if raw == "" {
continue
}
if IsThreadsSearchable(raw) {
if !add(raw) {
return out
}
continue
}
for i, v := range SearchableTermVariants(raw, true) {
if i >= MaxSearchableVariantsPerTerm {
break
}
if !add(v) {
return out
}
}
}
return out
}
func stripSearchFillers(s string) string {
for _, filler := range searchFillers {
s = strings.ReplaceAll(s, filler, "")
}
return strings.Trim(s, " ,,、。!?!?:的了嗎呢啊喔唷")
}
func splitSearchParts(raw string) []string {
var b strings.Builder
parts := make([]string, 0, 4)
flush := func() {
if value := strings.TrimSpace(b.String()); value != "" {
parts = append(parts, value)
}
b.Reset()
}
for _, r := range raw {
if unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.Is(unicode.Han, r) || unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r) {
b.WriteRune(r)
} else {
flush()
}
}
flush()
return parts
}

View File

@ -34,7 +34,7 @@ func TestIsThreadsSearchable(t *testing.T) {
bad := []string{ bad := []string{
"", "",
"a", "a",
"台北 婚攝 推薦", // 3 tokens "台北 婚攝 推薦", // 3 tokens
"這是一個超長關鍵字超過十二字", // too long "這是一個超長關鍵字超過十二字", // too long
"保母 AND 求推薦", "保母 AND 求推薦",
"保母#推薦", "保母#推薦",
@ -50,3 +50,51 @@ func TestIsThreadsSearchable(t *testing.T) {
} }
} }
} }
func TestSearchableTermVariantsKeepsShortAndShortensLong(t *testing.T) {
got := SearchableTermVariants("保母 求推薦", true)
if len(got) != 1 || got[0] != "保母 求推薦" {
t.Fatalf("short term variants=%v", got)
}
got = SearchableTermVariants("晚上睡覺容易口乾舌燥怎麼辦", true)
if len(got) == 0 {
t.Fatal("long sentence produced no variants")
}
for _, term := range got {
if !IsThreadsSearchable(term) {
t.Fatalf("variant %q is not searchable", term)
}
}
hasDry := false
hasSleep := false
for _, term := range got {
if term == "口乾舌燥" {
hasDry = true
}
if term == "晚上睡覺" {
hasSleep = true
}
}
if !hasDry || !hasSleep {
t.Fatalf("variants=%v want 晚上睡覺 and 口乾舌燥", got)
}
got = SearchableTermVariants("台北 婚攝 推薦 價格", true)
if len(got) == 0 || got[0] != "台北 婚攝" {
t.Fatalf("multi-token variants=%v want 台北 婚攝 first", got)
}
}
func TestExpandSearchTermsShortensUnsearchable(t *testing.T) {
got := ExpandSearchTerms([]string{"求推薦", "晚上睡覺容易口乾舌燥怎麼辦"}, 20)
if len(got) < 2 {
t.Fatalf("expanded=%v", got)
}
if got[0] != "求推薦" {
t.Fatalf("kept original first, got %v", got)
}
for _, term := range got {
if !IsThreadsSearchable(term) {
t.Fatalf("expanded term %q is not searchable", term)
}
}
}

View File

@ -231,6 +231,13 @@ func (w *RadarWatch) Normalize() error {
} }
func normalizeTerms(in []string, field string, max int) ([]string, error) { func normalizeTerms(in []string, field string, max int) ([]string, error) {
if field == "terms" {
out := ExpandSearchTerms(in, max)
if len(out) > max {
return nil, fmt.Errorf("%w: %s exceeds %d items", ErrValidation, field, max)
}
return out, nil
}
out := make([]string, 0, len(in)) out := make([]string, 0, len(in))
seen := map[string]bool{} seen := map[string]bool{}
for _, raw := range in { for _, raw := range in {

View File

@ -0,0 +1,155 @@
package repository
import (
"context"
"fmt"
"strings"
"testing"
"time"
"apps/backend/internal/module/radar/domain"
"go.mongodb.org/mongo-driver/bson"
)
func TestReviewStateListClauseLegacyPending(t *testing.T) {
pending := fmt.Sprintf("%#v", reviewStateListClause(domain.ReviewPending))
for _, part := range []string{domain.ReviewPending, "$nin", domain.OppAccepted, domain.OppDismissed} {
if !strings.Contains(pending, part) {
t.Fatalf("legacy pending clause missing %q: %s", part, pending)
}
}
completed := fmt.Sprintf("%#v", reviewStateListClause(domain.ReviewCompleted))
if !strings.Contains(completed, domain.ReviewCompleted) || !strings.Contains(completed, domain.OppAccepted) {
t.Fatalf("completed clause: %s", completed)
}
removed := fmt.Sprintf("%#v", reviewStateListClause(domain.ReviewRemoved))
if !strings.Contains(removed, domain.ReviewRemoved) || !strings.Contains(removed, domain.OppDismissed) {
t.Fatalf("removed clause: %s", removed)
}
}
func TestInboxTimeScopeClauseOldPostedCreatedTodayNoLastMatched(t *testing.T) {
from, to := domain.LocalDayBounds(domain.NowNano(), domain.DisplayLocation())
old := from - int64(48*time.Hour)
clause := inboxTimeScopeClause(from, to)
justFound := bson.M{"posted_at": old, "created_at": from + 1}
if !bsonMMatches(clause, justFound) {
t.Fatalf("posted=old created=today last_matched missing must match today $or; clause=%#v", clause)
}
zeroLast := bson.M{"posted_at": old, "created_at": from + 1, "last_matched_at": int64(0)}
if !bsonMMatches(clause, zeroLast) {
t.Fatalf("posted=old created=today last_matched=0 must match today $or; clause=%#v", clause)
}
staleMatch := bson.M{"posted_at": old, "created_at": from + 1, "last_matched_at": from - int64(24*time.Hour)}
if bsonMMatches(clause, staleMatch) {
t.Fatal("old last_matched must not fall back to created_at")
}
}
func TestListOpportunitiesCreatedTodayOldPostedWithoutUpsert(t *testing.T) {
ctx := context.Background()
m := NewMemory()
now := domain.NowNano()
from, to := domain.LocalDayBounds(now, domain.DisplayLocation())
row := sampleOpp("seed-no-upsert", []string{"漏水"}, 80)
row.ID = "seed-no-upsert"
row.PostedAt = from - int64(48*time.Hour)
row.CreatedAt = from + 1
row.LastMatchedAt = 0
row.ReviewState = ""
m.opportunities[row.ID] = row
pending, total, err := m.ListOpportunities(ctx, 42, domain.OpportunityListFilter{
ReviewState: domain.ReviewPending, TimeScope: "today", PostedFrom: from, PostedTo: to,
})
if err != nil || total != 1 || len(pending) != 1 || pending[0].ID != "seed-no-upsert" {
t.Fatalf("no-backfill just-found row missing from pending today: total=%d list=%d err=%v", total, len(pending), err)
}
}
func bsonMMatches(query bson.M, doc bson.M) bool {
if or, ok := query["$or"]; ok && !bsonOrMatches(or, doc) {
return false
}
if and, ok := query["$and"]; ok && !bsonAndMatches(and, doc) {
return false
}
for key, cond := range query {
if key == "$or" || key == "$and" {
continue
}
if !bsonFieldMatches(key, cond, doc) {
return false
}
}
return true
}
func bsonOrMatches(raw any, doc bson.M) bool {
items, ok := raw.(bson.A)
if !ok {
return false
}
for _, item := range items {
clause, ok := item.(bson.M)
if ok && bsonMMatches(clause, doc) {
return true
}
}
return false
}
func bsonAndMatches(raw any, doc bson.M) bool {
items, ok := raw.(bson.A)
if !ok {
return false
}
for _, item := range items {
clause, ok := item.(bson.M)
if !ok || !bsonMMatches(clause, doc) {
return false
}
}
return true
}
func bsonFieldMatches(key string, cond any, doc bson.M) bool {
got, exists := doc[key]
switch c := cond.(type) {
case bson.M:
if flag, ok := c["$exists"]; ok {
want, _ := flag.(bool)
if exists != want {
return false
}
}
if gte, ok := c["$gte"]; ok {
if !exists || toQueryInt64(got) < toQueryInt64(gte) {
return false
}
}
if lt, ok := c["$lt"]; ok {
if !exists || toQueryInt64(got) >= toQueryInt64(lt) {
return false
}
}
return true
default:
return exists && toQueryInt64(got) == toQueryInt64(c)
}
}
func toQueryInt64(v any) int64 {
switch n := v.(type) {
case int64:
return n
case int:
return int64(n)
case float64:
return int64(n)
default:
return 0
}
}

View File

@ -253,11 +253,19 @@ func (m *Memory) ListOpportunities(_ context.Context, ownerUID int64, f domain.O
if f.CreatedTo > 0 && o.CreatedAt >= f.CreatedTo { if f.CreatedTo > 0 && o.CreatedAt >= f.CreatedTo {
continue continue
} }
if f.PostedFrom > 0 && o.PostedAt < f.PostedFrom { if f.PostedFrom > 0 || f.PostedTo > 0 {
continue if f.TimeScope == "today" || f.TimeScope == "7d" {
} if !domain.InInboxTimeScope(o.PostedAt, o.LastMatchedAt, o.CreatedAt, f.PostedFrom, f.PostedTo) {
if f.PostedTo > 0 && o.PostedAt >= f.PostedTo { continue
continue }
} else {
if f.PostedFrom > 0 && o.PostedAt < f.PostedFrom {
continue
}
if f.PostedTo > 0 && o.PostedAt >= f.PostedTo {
continue
}
}
} }
matched = append(matched, cloneOpportunity(o)) matched = append(matched, cloneOpportunity(o))
} }

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"testing" "testing"
"time"
"apps/backend/internal/module/radar/domain" "apps/backend/internal/module/radar/domain"
) )
@ -146,6 +147,52 @@ func TestUpdateStatusAndOverride(t *testing.T) {
} }
} }
func TestListOpportunitiesLegacyPendingAndResweptToday(t *testing.T) {
ctx := context.Background()
m := NewMemory()
now := domain.NowNano()
from, to := domain.LocalDayBounds(now, domain.DisplayLocation())
old := from - int64(72*time.Hour)
legacy := sampleOpp("legacy-pending", []string{"漏水"}, 80)
legacy.ReviewState = ""
legacy.PostedAt = old
legacy.CreatedAt = old
legacy.LastMatchedAt = 0
if _, err := m.UpsertByExternalID(ctx, legacy); err != nil {
t.Fatal(err)
}
// Re-sweep the same source: merge must not hide the card from today's inbox.
if _, err := m.UpsertByExternalID(ctx, &domain.Opportunity{
OwnerUID: 42, ExternalID: "legacy-pending", MatchedTerms: []string{"抓漏"},
}); err != nil {
t.Fatal(err)
}
pending, total, err := m.ListOpportunities(ctx, 42, domain.OpportunityListFilter{
ReviewState: domain.ReviewPending, TimeScope: "today", PostedFrom: from, PostedTo: to,
})
if err != nil || total != 1 || len(pending) != 1 {
t.Fatalf("legacy rematch missing from pending today: total=%d list=%d err=%v", total, len(pending), err)
}
if pending[0].ReviewState != domain.ReviewPending {
t.Fatalf("legacy review_state = %q", pending[0].ReviewState)
}
undated := sampleOpp("undated-today", []string{"漏水"}, 70)
undated.PostedAt = 0
undated.CreatedAt = now
if _, err := m.UpsertByExternalID(ctx, undated); err != nil {
t.Fatal(err)
}
pending, total, err = m.ListOpportunities(ctx, 42, domain.OpportunityListFilter{
ReviewState: domain.ReviewPending, TimeScope: "today", PostedFrom: from, PostedTo: to,
})
if err != nil || total != 2 {
t.Fatalf("undated created-today row should join pending today: total=%d err=%v", total, err)
}
}
func TestReviewStateGuardAndTombstone(t *testing.T) { func TestReviewStateGuardAndTombstone(t *testing.T) {
ctx := context.Background() ctx := context.Background()
m := NewMemory() m := NewMemory()

View File

@ -37,21 +37,17 @@ func (s *MonStore) UpsertByExternalID(ctx context.Context, o *domain.Opportunity
"external_id": externalID, "external_id": externalID,
}) })
if err == nil { if err == nil {
terms := domain.NormalizeMatchedTerms(o.MatchedTerms) now := domain.NowNano()
if len(terms) > 0 { set := bson.M{"updated_at": now, "last_matched_at": now}
_, uerr := s.opportunities.UpdateOne(ctx, update := bson.M{"$set": set}
bson.M{"_id": existing.ID}, if terms := domain.NormalizeMatchedTerms(o.MatchedTerms); len(terms) > 0 {
bson.M{ update["$addToSet"] = bson.M{"matched_terms": bson.M{"$each": terms}}
"$addToSet": bson.M{"matched_terms": bson.M{"$each": terms}}, }
"$set": bson.M{"updated_at": domain.NowNano()}, if _, uerr := s.opportunities.UpdateOne(ctx, bson.M{"_id": existing.ID}, update); uerr != nil {
}) return nil, uerr
if uerr != nil { }
return nil, uerr if rerr := s.opportunities.FindOne(ctx, &existing, bson.M{"_id": existing.ID}); rerr != nil {
} return nil, rerr
// re-read after merge
if rerr := s.opportunities.FindOne(ctx, &existing, bson.M{"_id": existing.ID}); rerr != nil {
return nil, rerr
}
} }
for _, match := range o.ProductMatches { for _, match := range o.ProductMatches {
if _, merr := s.MergeProductMatch(ctx, o.OwnerUID, existing.ID, match); merr != nil { if _, merr := s.MergeProductMatch(ctx, o.OwnerUID, existing.ID, match); merr != nil {
@ -85,6 +81,9 @@ func (s *MonStore) UpsertByExternalID(ctx context.Context, o *domain.Opportunity
if o.ReviewState == "" { if o.ReviewState == "" {
o.ReviewState = reviewStateFor(o) o.ReviewState = reviewStateFor(o)
} }
if o.LastMatchedAt == 0 {
o.LastMatchedAt = now
}
o.ExternalID = externalID o.ExternalID = externalID
_, err = s.opportunities.InsertOne(ctx, o) _, err = s.opportunities.InsertOne(ctx, o)
@ -242,10 +241,7 @@ func (s *MonStore) ListOpportunities(ctx context.Context, ownerUID int64, f doma
q["priority_band"] = f.PriorityBand q["priority_band"] = f.PriorityBand
} }
if f.ReviewState != "" { if f.ReviewState != "" {
q["$or"] = bson.A{ appendAnd(q, reviewStateListClause(f.ReviewState))
bson.M{"review_state": f.ReviewState},
bson.M{"review_state": bson.M{"$exists": false}, "status": map[string]string{domain.ReviewCompleted: domain.OppAccepted, domain.ReviewRemoved: domain.OppDismissed}[f.ReviewState]},
}
} }
switch f.MatchState { switch f.MatchState {
case "eligible": case "eligible":
@ -255,9 +251,9 @@ func (s *MonStore) ListOpportunities(ctx context.Context, ownerUID int64, f doma
case "excluded": case "excluded":
q["product_matches.excluded"] = true q["product_matches.excluded"] = true
case "generic": case "generic":
q["$or"] = bson.A{bson.M{"product_matches": bson.M{"$exists": false}}, bson.M{"product_matches": bson.M{"$size": 0}}} appendAnd(q, bson.M{"$or": bson.A{bson.M{"product_matches": bson.M{"$exists": false}}, bson.M{"product_matches": bson.M{"$size": 0}}}})
case "stale": case "stale":
q["posted_at"] = bson.M{"$lt": domain.NowNano() - int64(domain.MaxFreshnessDays)*24*int64(time.Hour)} appendAnd(q, bson.M{"posted_at": bson.M{"$lt": domain.NowNano() - int64(domain.MaxFreshnessDays)*24*int64(time.Hour)}})
} }
if f.CreatedFrom > 0 || f.CreatedTo > 0 { if f.CreatedFrom > 0 || f.CreatedTo > 0 {
rng := bson.M{} rng := bson.M{}
@ -270,14 +266,18 @@ func (s *MonStore) ListOpportunities(ctx context.Context, ownerUID int64, f doma
q["created_at"] = rng q["created_at"] = rng
} }
if f.PostedFrom > 0 || f.PostedTo > 0 { if f.PostedFrom > 0 || f.PostedTo > 0 {
rng := bson.M{} if f.TimeScope == "today" || f.TimeScope == "7d" {
if f.PostedFrom > 0 { appendAnd(q, inboxTimeScopeClause(f.PostedFrom, f.PostedTo))
rng["$gte"] = f.PostedFrom } else {
rng := bson.M{}
if f.PostedFrom > 0 {
rng["$gte"] = f.PostedFrom
}
if f.PostedTo > 0 {
rng["$lt"] = f.PostedTo
}
q["posted_at"] = rng
} }
if f.PostedTo > 0 {
rng["$lt"] = f.PostedTo
}
q["posted_at"] = rng
} }
total, err := s.opportunities.CountDocuments(ctx, q) total, err := s.opportunities.CountDocuments(ctx, q)
@ -446,3 +446,77 @@ func (s *MonStore) SetOpportunityOverride(ctx context.Context, id string, ov *do
} }
return nil return nil
} }
func appendAnd(q bson.M, clause bson.M) {
if len(clause) == 0 {
return
}
if existing, ok := q["$and"].(bson.A); ok {
q["$and"] = append(existing, clause)
return
}
q["$and"] = bson.A{clause}
}
// reviewStateListClause implements spec §3.2: missing review_state is derived
// from OpportunityStatus so a list query never requires a one-shot migration.
func reviewStateListClause(state string) bson.M {
missing := bson.A{
bson.M{"review_state": bson.M{"$exists": false}},
bson.M{"review_state": ""},
}
switch state {
case domain.ReviewPending:
return bson.M{"$or": bson.A{
bson.M{"review_state": domain.ReviewPending},
bson.M{"$and": bson.A{
bson.M{"$or": missing},
bson.M{"status": bson.M{"$nin": []string{domain.OppAccepted, domain.OppDismissed}}},
}},
}}
case domain.ReviewCompleted:
return bson.M{"$or": bson.A{
bson.M{"review_state": domain.ReviewCompleted},
bson.M{"$and": bson.A{
bson.M{"$or": missing},
bson.M{"status": domain.OppAccepted},
}},
}}
case domain.ReviewRemoved:
return bson.M{"$or": bson.A{
bson.M{"review_state": domain.ReviewRemoved},
bson.M{"$and": bson.A{
bson.M{"$or": missing},
bson.M{"status": domain.OppDismissed},
}},
}}
default:
return bson.M{"review_state": state}
}
}
func inboxTimeScopeClause(from, to int64) bson.M {
rng := bson.M{}
if from > 0 {
rng["$gte"] = from
}
if to > 0 {
rng["$lt"] = to
}
// Same predicate as domain.InInboxTimeScope: posted in range, or
// last_matched in range, or last_matched missing/0 and created in range.
// Must not gate created_at on posted_at — a just-found older post has a
// real posted_at from yesterday and no last_matched_at (no-backfill).
missingLastMatched := bson.A{
bson.M{"last_matched_at": bson.M{"$exists": false}},
bson.M{"last_matched_at": int64(0)},
}
return bson.M{"$or": bson.A{
bson.M{"posted_at": rng},
bson.M{"last_matched_at": rng},
bson.M{"$and": bson.A{
bson.M{"$or": missingLastMatched},
bson.M{"created_at": rng},
}},
}}
}

View File

@ -62,23 +62,35 @@ func (s *Service) UpdateDemandMap(ctx context.Context, ownerUID int64, value *do
} }
func baselineDemandMap(product *ProductCatalogProduct) *domain.DemandMap { func baselineDemandMap(product *ProductCatalogProduct) *domain.DemandMap {
mapPhrase := func(text, kind, basisKind string) domain.DemandMapPhrase { mapPhrase := func(text, kind, basisKind, basisText string) domain.DemandMapPhrase {
return domain.DemandMapPhrase{Text: strings.TrimSpace(text), Kind: kind, BasisKind: basisKind, BasisText: product.Label, Origin: "product", Enabled: strings.TrimSpace(text) != ""} return domain.DemandMapPhrase{Text: strings.TrimSpace(text), Kind: kind, BasisKind: basisKind, BasisText: basisText, Origin: "product", Enabled: strings.TrimSpace(text) != ""}
} }
phrases := func(items []string, kind, basisKind string) []domain.DemandMapPhrase { phrases := func(items []string, kind, basisKind string, include bool) []domain.DemandMapPhrase {
out := make([]domain.DemandMapPhrase, 0, len(items)) out := make([]domain.DemandMapPhrase, 0, len(items))
for _, item := range items { for _, item := range items {
if strings.TrimSpace(item) != "" { raw := strings.TrimSpace(item)
out = append(out, mapPhrase(item, kind, basisKind)) if raw == "" {
continue
}
if !include {
out = append(out, mapPhrase(raw, kind, basisKind, product.Label))
continue
}
variants := domain.SearchableTermVariants(raw, true)
if len(variants) > domain.MaxSearchableVariantsPerTerm {
variants = variants[:domain.MaxSearchableVariantsPerTerm]
}
for _, term := range variants {
out = append(out, mapPhrase(term, kind, basisKind, raw))
} }
} }
return out return out
} }
pain := phrases(product.PainPoints, "pain", "pain_point") pain := phrases(product.PainPoints, "pain", "pain_point", true)
scenario := phrases([]string{product.ProductContext}, "scenario", "product_context") scenario := phrases([]string{product.ProductContext}, "scenario", "product_context", true)
outcomes := phrases(product.MatchTags, "outcome", "match_tag") outcomes := phrases(product.MatchTags, "outcome", "match_tag", true)
solution := phrases(product.ProviderCapabilityTerms, "solution", "provider_capability") solution := phrases(product.ProviderCapabilityTerms, "solution", "provider_capability", true)
exclusions := phrases(product.ProviderExcludeTerms, "exclusion", "provider_exclude") exclusions := phrases(product.ProviderExcludeTerms, "exclusion", "provider_exclude", false)
state := "incomplete" state := "incomplete"
if len(pain) > 0 && len(scenario) > 0 && len(solution) > 0 { if len(pain) > 0 && len(scenario) > 0 && len(solution) > 0 {
state = "ready" state = "ready"

View File

@ -41,7 +41,7 @@ func (s *Service) EnrichDemandMap(ctx context.Context, ownerUID int64, productID
if s.AI == nil && s.AIRegistry == nil && s.ResolveAI == nil { if s.AI == nil && s.AIRegistry == nil && s.ResolveAI == nil {
return nil, fmt.Errorf("%w: AI provider unavailable", domain.ErrNotReady) return nil, fmt.Errorf("%w: AI provider unavailable", domain.ErrNotReady)
} }
prompt := fmt.Sprintf("請只輸出 JSON 物件,根據需求地圖補充使用者會說的短詞,不要使用品牌或產品名稱。痛點=%q情境=%q結果=%q能力=%q。欄位為 pain_phrases、scenario_phrases、desired_outcomes、solution_signals、exclusion_signals、custom_phrases每則含 text、kind、basis_kind、basis_text、origin=ai、enabled=true。", phraseTexts(current.PainPhrases), phraseTexts(current.ScenarioPhrases), phraseTexts(current.DesiredOutcomes), phraseTexts(current.SolutionSignals)) prompt := fmt.Sprintf("請只輸出 JSON 物件,根據需求地圖補充使用者會說的短詞,不要使用品牌或產品名稱。痛點=%q情境=%q結果=%q能力=%q。欄位為 pain_phrases、scenario_phrases、desired_outcomes、solution_signals、exclusion_signals、custom_phrases每則含 text、kind、basis_kind、basis_text、origin=ai、enabled=true。include 類 text 必須是 Threads 可搜短詞:最多 2 個 token、中文每 token 24 字、去空格後 ≤12 字、禁止標點。", phraseTexts(current.PainPhrases), phraseTexts(current.ScenarioPhrases), phraseTexts(current.DesiredOutcomes), phraseTexts(current.SolutionSignals))
raw, err := s.completeAI(ctx, ownerUID, prompt) raw, err := s.completeAI(ctx, ownerUID, prompt)
if err != nil { if err != nil {
return nil, err return nil, err
@ -61,9 +61,30 @@ func (s *Service) EnrichDemandMap(ctx context.Context, ownerUID int64, productID
p.Text = strings.TrimSpace(p.Text) p.Text = strings.TrimSpace(p.Text)
p.Origin = "ai" p.Origin = "ai"
p.Enabled = p.Enabled && p.Text != "" p.Enabled = p.Enabled && p.Text != ""
if p.Enabled && !seen[strings.ToLower(p.Text)] { if !p.Enabled {
out = append(out, p) continue
seen[strings.ToLower(p.Text)] = true }
texts := []string{p.Text}
if p.Kind != "exclusion" {
texts = domain.SearchableTermVariants(p.Text, true)
if len(texts) > domain.MaxSearchableVariantsPerTerm {
texts = texts[:domain.MaxSearchableVariantsPerTerm]
}
}
basis := strings.TrimSpace(p.BasisText)
if basis == "" {
basis = p.Text
}
for _, text := range texts {
key := strings.ToLower(text)
if text == "" || seen[key] {
continue
}
cp := p
cp.Text = text
cp.BasisText = basis
out = append(out, cp)
seen[key] = true
} }
} }
return out return out

View File

@ -37,6 +37,11 @@ func TestDemandMapBaselineAndOptimisticVersion(t *testing.T) {
if first.State != "ready" || first.MapVersion != 1 || len(first.DemandInputVersion) != len("demand-")+16 { if first.State != "ready" || first.MapVersion != 1 || len(first.DemandInputVersion) != len("demand-")+16 {
t.Fatalf("unexpected baseline: %+v", first) t.Fatalf("unexpected baseline: %+v", first)
} }
for _, p := range append(append([]domain.DemandMapPhrase{}, first.PainPhrases...), append(first.ScenarioPhrases, first.SolutionSignals...)...) {
if !domain.IsThreadsSearchable(p.Text) {
t.Fatalf("baseline phrase %q is not searchable", p.Text)
}
}
first.PainPhrases[0].Text = "新的痛點" first.PainPhrases[0].Text = "新的痛點"
updated, err := svc.UpdateDemandMap(context.Background(), owner, first, 1) updated, err := svc.UpdateDemandMap(context.Background(), owner, first, 1)
if err != nil { if err != nil {

View File

@ -77,10 +77,7 @@ func (s *Service) exploreOpportunities(ctx context.Context, ownerUID int64, rawT
if productContext != nil { if productContext != nil {
if dm, derr := s.GetDemandMap(ctx, ownerUID, productID); derr == nil { if dm, derr := s.GetDemandMap(ctx, ownerUID, productID); derr == nil {
if plan, perr := BuildQueryPlan(dm, productContext.ProductLabel); perr == nil && plan != nil { if plan, perr := BuildQueryPlan(dm, productContext.ProductLabel); perr == nil && plan != nil {
w.Terms = make([]string, 0, len(plan.Groups)) w = mergeFetchWatch(w, plan)
for _, group := range plan.Groups {
w.Terms = append(w.Terms, group.Query)
}
} }
} }
} }
@ -110,7 +107,7 @@ func (s *Service) exploreOpportunities(ctx context.Context, ownerUID int64, rawT
PrefilterReviewCount: prefilter.Review, PrefilterRejectedCount: prefilter.Rejected, PrefilterReviewCount: prefilter.Review, PrefilterRejectedCount: prefilter.Rejected,
}) })
created, judged, truncated, failed, judgeCredits, perr := s.ProcessCandidates( created, _, judged, truncated, failed, judgeCredits, perr := s.ProcessCandidates(
ctx, ownerUID, w, profile, sw.ID, cands, nil, ctx, ownerUID, w, profile, sw.ID, cands, nil,
) )
if perr != nil { if perr != nil {

View File

@ -29,18 +29,18 @@ func (s *Service) ProcessCandidates(
sweepID string, sweepID string,
cands []*domain.CandidatePost, cands []*domain.CandidatePost,
alreadyJudged map[string]bool, alreadyJudged map[string]bool,
) (created, judged, truncated, failed int, credits int, err error) { ) (created, rematched, judged, truncated, failed int, credits int, err error) {
matchEvaluated, matchMerged, fitRejected, budgetDeferred := 0, 0, 0, 0 matchEvaluated, matchMerged, fitRejected, budgetDeferred := 0, 0, 0, 0
if alreadyJudged == nil { if alreadyJudged == nil {
alreadyJudged = map[string]bool{} alreadyJudged = map[string]bool{}
} }
maxDaily, err := s.MaxDailyOpportunities(ctx, ownerUID) maxDaily, err := s.MaxDailyOpportunities(ctx, ownerUID)
if err != nil { if err != nil {
return 0, 0, 0, 0, 0, err return 0, 0, 0, 0, 0, 0, err
} }
todayCount, err := s.Repo.CountToday(ctx, ownerUID, domain.NowNano()) todayCount, err := s.Repo.CountToday(ctx, ownerUID, domain.NowNano())
if err != nil { if err != nil {
return 0, 0, 0, 0, 0, err return 0, 0, 0, 0, 0, 0, err
} }
remaining := maxDaily - int(todayCount) remaining := maxDaily - int(todayCount)
if remaining < 0 { if remaining < 0 {
@ -64,6 +64,7 @@ func (s *Service) ProcessCandidates(
pcredits, perr := s.mergeExistingProductCandidate(ctx, ownerUID, watch, c, existing) pcredits, perr := s.mergeExistingProductCandidate(ctx, ownerUID, watch, c, existing)
credits += pcredits credits += pcredits
judged++ judged++
rematched++
if perr == nil { if perr == nil {
matchMerged++ matchMerged++
} else { } else {
@ -77,7 +78,7 @@ func (s *Service) ProcessCandidates(
if sweepID != "" { if sweepID != "" {
_, _ = s.Repo.UpdateSweep(ctx, sweepID, domain.SweepDelta{JudgedCount: judged, TruncatedCount: truncated, BudgetDeferredCount: budgetDeferred, CreditsUsed: credits, CreditJudge: credits, MatchEvaluatedCount: matchEvaluated, MatchMergedCount: matchMerged, FitRejectedCount: fitRejected}) _, _ = s.Repo.UpdateSweep(ctx, sweepID, domain.SweepDelta{JudgedCount: judged, TruncatedCount: truncated, BudgetDeferredCount: budgetDeferred, CreditsUsed: credits, CreditJudge: credits, MatchEvaluatedCount: matchEvaluated, MatchMergedCount: matchMerged, FitRejectedCount: fitRejected})
} }
return 0, judged, truncated, failed, credits, nil return 0, rematched, judged, truncated, failed, credits, nil
} }
var scored []scoredCandidate var scored []scoredCandidate
@ -112,6 +113,7 @@ func (s *Service) ProcessCandidates(
failed++ failed++
} else { } else {
credits += pcredits credits += pcredits
rematched++
if watch != nil && watch.ContextMode == domain.WatchContextProduct && productMatchFor(existing, watch.ProductID) == nil { if watch != nil && watch.ContextMode == domain.WatchContextProduct && productMatchFor(existing, watch.ProductID) == nil {
matchMerged++ matchMerged++
} }
@ -158,11 +160,16 @@ func (s *Service) ProcessCandidates(
res := sc.result res := sc.result
// rejected always stored if reasons ok // rejected always stored if reasons ok
if res.Status == domain.OppRejected { if res.Status == domain.OppRejected {
if perr := s.persistOne(ctx, ownerUID, watch, sc.cand, res); perr != nil { inserted, perr := s.persistOne(ctx, ownerUID, watch, sc.cand, res)
if perr != nil {
failed++ failed++
continue continue
} }
created++ if inserted {
created++
} else {
rematched++
}
continue continue
} }
if remaining <= 0 { if remaining <= 0 {
@ -170,11 +177,16 @@ func (s *Service) ProcessCandidates(
budgetDeferred++ budgetDeferred++
continue continue
} }
if perr := s.persistOne(ctx, ownerUID, watch, sc.cand, res); perr != nil { inserted, perr := s.persistOne(ctx, ownerUID, watch, sc.cand, res)
if perr != nil {
failed++ failed++
continue continue
} }
created++ if inserted {
created++
} else {
rematched++
}
remaining-- remaining--
} }
@ -192,15 +204,15 @@ func (s *Service) ProcessCandidates(
BudgetDeferredCount: budgetDeferred, BudgetDeferredCount: budgetDeferred,
} }
if _, uerr := s.Repo.UpdateSweep(ctx, sweepID, delta); uerr != nil { if _, uerr := s.Repo.UpdateSweep(ctx, sweepID, delta); uerr != nil {
return created, judged, truncated, failed, credits, uerr return created, rematched, judged, truncated, failed, credits, uerr
} }
} }
return created, judged, truncated, failed, credits, nil return created, rematched, judged, truncated, failed, credits, nil
} }
func (s *Service) persistOne(ctx context.Context, ownerUID int64, watch *domain.RadarWatch, cand *domain.CandidatePost, res *JudgeResult) error { func (s *Service) persistOne(ctx context.Context, ownerUID int64, watch *domain.RadarWatch, cand *domain.CandidatePost, res *JudgeResult) (inserted bool, err error) {
if err := domain.ValidateReasons(res.Reasons); err != nil { if err := domain.ValidateReasons(res.Reasons); err != nil {
return err return false, err
} }
watchID := "" watchID := ""
if watch != nil { if watch != nil {
@ -235,6 +247,7 @@ func (s *Service) persistOne(ctx context.Context, ownerUID int64, watch *domain.
EvidenceQualityScore: priority.EvidenceQuality, EvidenceQualityScore: priority.EvidenceQuality,
FreshnessScore: priority.Freshness, FreshnessScore: priority.Freshness,
DemandEvidence: priority.Evidence, DemandEvidence: priority.Evidence,
LastMatchedAt: domain.NowNano(),
} }
if watch != nil && watch.ContextMode == domain.WatchContextProduct { if watch != nil && watch.ContextMode == domain.WatchContextProduct {
if dm, derr := s.GetDemandMap(ctx, ownerUID, watch.ProductID); derr == nil && dm != nil { if dm, derr := s.GetDemandMap(ctx, ownerUID, watch.ProductID); derr == nil && dm != nil {
@ -245,11 +258,15 @@ func (s *Service) persistOne(ctx context.Context, ownerUID int64, watch *domain.
o.ProductMatches = []*domain.ProductMatch{domain.CloneProductMatch(res.ProductMatch)} o.ProductMatches = []*domain.ProductMatch{domain.CloneProductMatch(res.ProductMatch)}
} }
if err := mergeProductMatchIntoOpportunity(o, res.ProductMatch); res.ProductMatch != nil && err != nil { if err := mergeProductMatchIntoOpportunity(o, res.ProductMatch); res.ProductMatch != nil && err != nil {
return err return false, err
} }
if o.IntentBand == "" { if o.IntentBand == "" {
o.ApplyBandFromScore() o.ApplyBandFromScore()
} }
_, err := s.Repo.UpsertByExternalID(ctx, o) existing, gerr := s.Repo.GetByExternalID(ctx, ownerUID, cand.ExternalID)
return err already := gerr == nil && existing != nil
if _, err := s.Repo.UpsertByExternalID(ctx, o); err != nil {
return false, err
}
return !already, nil
} }

View File

@ -27,10 +27,10 @@ func seedProfileWatch(t *testing.T, svc *Service, owner int64) *domain.RadarWatc
t.Helper() t.Helper()
ctx := context.Background() ctx := context.Background()
p := &domain.ServiceProfile{ p := &domain.ServiceProfile{
OwnerUID: owner, OwnerUID: owner,
Services: []domain.ServiceItem{{Name: "婚禮攝影", Currency: "TWD"}}, Services: []domain.ServiceItem{{Name: "婚禮攝影", Currency: "TWD"}},
ServiceAreas: []string{"TPE"}, ServiceAreas: []string{"TPE"},
RemoteOk: false, RemoteOk: false,
} }
if err := p.Normalize(); err != nil { if err := p.Normalize(); err != nil {
// minimal normalize via save path // minimal normalize via save path
@ -98,7 +98,7 @@ func TestM2_OP02_ProviderOfferRejected(t *testing.T) {
svc.HitFetch = &fakeHits{ svc.HitFetch = &fakeHits{
path: domain.SweepPathAPI, path: domain.SweepPathAPI,
hits: []ThreadHit{{ hits: []ThreadHit{{
URL: "https://www.threads.net/@biz/post/2", URL: "https://www.threads.net/@biz/post/2",
Snippet: "婚攝接案中 歡迎洽詢我 限時優惠 dm me", Snippet: "婚攝接案中 歡迎洽詢我 限時優惠 dm me",
}}, }},
} }
@ -232,6 +232,57 @@ func TestM2_QT01_AtCapNoError(t *testing.T) {
} }
} }
func TestM2_ManualTriggerRunsAgainAfterDailySuccess(t *testing.T) {
ctx := context.Background()
jobs := jobUC.New(jobRepo.NewMemory())
svc := New(repository.NewMemory())
svc.SweepJobs = radarManualJobs{jobs: jobs}
now := domain.NowNano()
w := &domain.RadarWatch{
ID: "w-live", OwnerUID: 17, Terms: []string{"痛點"}, Status: domain.WatchActive,
CreatedAt: now, UpdatedAt: now,
}
if err := svc.Repo.SaveWatch(ctx, w); err != nil {
t.Fatal(err)
}
daily, err := jobs.ScheduleRadarSweep(ctx, 17, w.ID, now)
if err != nil {
t.Fatal(err)
}
claimed, err := jobs.ClaimNext(ctx, "w")
if err != nil || claimed.ID != daily.ID {
t.Fatalf("claim daily: %v", err)
}
if _, err := jobs.SucceedJob(ctx, daily.ID, "daily done"); err != nil {
t.Fatal(err)
}
manualID, err := svc.TriggerSweep(ctx, 17, w.ID)
if err != nil {
t.Fatal(err)
}
if manualID == daily.ID {
t.Fatal("立即巡邏 must not reuse the finished daily job")
}
}
type radarManualJobs struct{ jobs *jobUC.Service }
func (a radarManualJobs) ScheduleRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (string, error) {
j, err := a.jobs.ScheduleRadarSweep(ctx, ownerUID, watchID, runAt)
if err != nil {
return "", err
}
return j.ID, nil
}
func (a radarManualJobs) ScheduleManualRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (string, error) {
j, err := a.jobs.ScheduleManualRadarSweep(ctx, ownerUID, watchID, runAt)
if err != nil {
return "", err
}
return j.ID, nil
}
func TestM2_ManualTriggerPausedRejected(t *testing.T) { func TestM2_ManualTriggerPausedRejected(t *testing.T) {
ctx := context.Background() ctx := context.Background()
jobs := jobUC.New(jobRepo.NewMemory()) jobs := jobUC.New(jobRepo.NewMemory())

View File

@ -73,7 +73,10 @@ func (s *Service) TriggerSweep(ctx context.Context, ownerUID int64, watchID stri
if w.Status != domain.WatchActive { if w.Status != domain.WatchActive {
return "", fmt.Errorf("%w: only active watches can be swept (status=%s)", domain.ErrValidation, w.Status) return "", fmt.Errorf("%w: only active watches can be swept (status=%s)", domain.ErrValidation, w.Status)
} }
// Manual trigger is due now. // Manual trigger is due now and must not reuse a finished daily slot.
if manual, ok := s.SweepJobs.(ManualSweepJobScheduler); ok {
return manual.ScheduleManualRadarSweep(ctx, ownerUID, watchID, domain.NowNano())
}
return s.SweepJobs.ScheduleRadarSweep(ctx, ownerUID, watchID, domain.NowNano()) return s.SweepJobs.ScheduleRadarSweep(ctx, ownerUID, watchID, domain.NowNano())
} }

View File

@ -16,7 +16,10 @@ func TestProductFitStaleCandidateStillPersistsCompleteMatch(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if result.Status != domain.OppRejected || result.ProductMatch == nil || len(result.ProductMatch.Reasons) != 4 { if result.Status == domain.OppRejected {
t.Fatalf("a 20-day-old demand post must stay judgeable, got rejected: %+v", result)
}
if result.ProductMatch == nil || len(result.ProductMatch.Reasons) != 4 {
t.Fatalf("stale candidate lost product evidence: %+v", result) t.Fatalf("stale candidate lost product evidence: %+v", result)
} }
if result.ProductMatch.ProductFitScore == 0 { if result.ProductMatch.ProductFitScore == 0 {

View File

@ -37,13 +37,39 @@ func BuildQueryPlan(m *domain.DemandMap, productLabel string) (*domain.QueryPlan
} }
productLabel = strings.ToLower(domain.NormalizeSearchTerm(productLabel)) productLabel = strings.ToLower(domain.NormalizeSearchTerm(productLabel))
include := func(list []domain.DemandMapPhrase) []domain.DemandMapPhrase { include := func(list []domain.DemandMapPhrase) []domain.DemandMapPhrase {
out := make([]domain.DemandMapPhrase, 0, len(list))
for _, p := range list {
if !p.Enabled {
continue
}
variants := domain.SearchableTermVariants(p.Text, true)
if len(variants) > domain.MaxSearchableVariantsPerTerm {
variants = variants[:domain.MaxSearchableVariantsPerTerm]
}
basis := strings.TrimSpace(p.BasisText)
if basis == "" {
basis = strings.TrimSpace(p.Text)
}
for _, text := range variants {
if text == "" || strings.ToLower(text) == productLabel {
continue
}
cp := p
cp.Text = text
cp.BasisText = basis
out = append(out, cp)
}
}
return out
}
excludePhrases := func(list []domain.DemandMapPhrase) []domain.DemandMapPhrase {
out := make([]domain.DemandMapPhrase, 0, len(list)) out := make([]domain.DemandMapPhrase, 0, len(list))
for _, p := range list { for _, p := range list {
if !p.Enabled { if !p.Enabled {
continue continue
} }
text := domain.NormalizeSearchTerm(p.Text) text := domain.NormalizeSearchTerm(p.Text)
if text == "" || strings.ToLower(text) == productLabel || !domain.IsThreadsSearchable(text) { if text == "" {
continue continue
} }
p.Text = text p.Text = text
@ -53,11 +79,14 @@ func BuildQueryPlan(m *domain.DemandMap, productLabel string) (*domain.QueryPlan
} }
pains, scenarios := include(m.PainPhrases), include(m.ScenarioPhrases) pains, scenarios := include(m.PainPhrases), include(m.ScenarioPhrases)
outcomes, solutions := include(m.DesiredOutcomes), include(m.SolutionSignals) outcomes, solutions := include(m.DesiredOutcomes), include(m.SolutionSignals)
exclusions := include(m.ExclusionSignals) exclusions := excludePhrases(m.ExclusionSignals)
groups := make([]domain.QueryPlanGroup, 0, domain.MaxExploreTerms) groups := make([]domain.QueryPlanGroup, 0, domain.MaxExploreTerms)
seen := map[string]bool{} seen := map[string]bool{}
add := func(parts ...domain.DemandMapPhrase) { add := func(parts ...domain.DemandMapPhrase) {
if len(groups) >= domain.MaxExploreTerms {
return
}
terms := make([]string, 0, len(parts)) terms := make([]string, 0, len(parts))
basisKinds := make([]string, 0, len(parts)) basisKinds := make([]string, 0, len(parts))
basisTexts := make([]string, 0, len(parts)) basisTexts := make([]string, 0, len(parts))
@ -82,12 +111,18 @@ func BuildQueryPlan(m *domain.DemandMap, productLabel string) (*domain.QueryPlan
} }
for _, pain := range pains { for _, pain := range pains {
add(pain) add(pain)
if len(groups) >= domain.MaxExploreTerms {
break
}
for _, scenario := range scenarios { for _, scenario := range scenarios {
add(pain, scenario) add(pain, scenario)
if len(groups) >= domain.MaxExploreTerms { if len(groups) >= domain.MaxExploreTerms {
break break
} }
} }
if len(groups) >= domain.MaxExploreTerms {
break
}
for _, outcome := range outcomes { for _, outcome := range outcomes {
add(pain, outcome) add(pain, outcome)
if len(groups) >= domain.MaxExploreTerms { if len(groups) >= domain.MaxExploreTerms {

View File

@ -38,3 +38,24 @@ func TestBuildQueryPlanRejectsIncomplete(t *testing.T) {
t.Fatal("incomplete map must not produce a plan") t.Fatal("incomplete map must not produce a plan")
} }
} }
func TestBuildQueryPlanShortensLongDemandPhrases(t *testing.T) {
m := &domain.DemandMap{
ProductID: "p1", DemandInputVersion: "demand-a", MapVersion: 1, State: "ready",
PainPhrases: []domain.DemandMapPhrase{{Text: "晚上睡覺容易口乾舌燥怎麼辦", Kind: "pain", Enabled: true}},
ScenarioPhrases: []domain.DemandMapPhrase{{Text: "換季日常修護", Kind: "scenario", Enabled: true}},
SolutionSignals: []domain.DemandMapPhrase{{Text: "保濕", Kind: "solution", Enabled: true}},
}
plan, err := BuildQueryPlan(m, "舒緩精華")
if err != nil {
t.Fatal(err)
}
if len(plan.Groups) == 0 {
t.Fatal("long demand phrases must still compile into a plan")
}
for _, group := range plan.Groups {
if !domain.IsThreadsSearchable(group.Query) {
t.Fatalf("unsearchable query: %+v", group)
}
}
}

View File

@ -6,7 +6,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"unicode"
"apps/backend/internal/module/radar/domain" "apps/backend/internal/module/radar/domain"
usageDomain "apps/backend/internal/module/usage/domain" usageDomain "apps/backend/internal/module/usage/domain"
@ -294,70 +293,7 @@ func productSuggestionFallback(p *ProductContextSnapshot, limit int) []domain.Wa
// productSearchTermVariants keeps fallback terms short without inventing // productSearchTermVariants keeps fallback terms short without inventing
// product names. Exact short fields win; longer CJK fields yield small windows. // product names. Exact short fields win; longer CJK fields yield small windows.
func productSearchTermVariants(raw string, include bool) []string { func productSearchTermVariants(raw string, include bool) []string {
raw = domain.NormalizeSearchTerm(raw) return domain.SearchableTermVariants(raw, include)
if raw == "" {
return nil
}
parts := splitProductSearchParts(raw)
if len(parts) == 0 {
parts = []string{raw}
}
seen := map[string]bool{}
out := make([]string, 0, 8)
add := func(term string) {
term = domain.NormalizeSearchTerm(term)
if term == "" || seen[term] {
return
}
if include {
if !domain.IsThreadsSearchable(term) {
return
}
} else if n := len([]rune(term)); n < domain.MinTermLen || n > domain.MaxTermLen {
return
}
seen[term] = true
out = append(out, term)
}
if domain.IsThreadsSearchable(raw) {
add(raw)
}
for _, part := range parts {
if domain.IsThreadsSearchable(part) {
add(part)
continue
}
runes := []rune(part)
for width := 4; width >= 2; width-- {
if len(runes) < width {
continue
}
for start := 0; start+width <= len(runes) && len(out) < 8; start++ {
add(string(runes[start : start+width]))
}
}
}
return out
}
func splitProductSearchParts(raw string) []string {
var b strings.Builder
parts := make([]string, 0, 4)
flush := func() {
if value := strings.TrimSpace(b.String()); value != "" {
parts = append(parts, value)
}
b.Reset()
}
for _, r := range raw {
if unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.Is(unicode.Han, r) || unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r) {
b.WriteRune(r)
} else {
flush()
}
}
flush()
return parts
} }
/* /*

View File

@ -160,7 +160,7 @@ func TestSuggestRespectsLimit(t *testing.T) {
} }
func TestSuggestDropsUnusableItems(t *testing.T) { func TestSuggestDropsUnusableItems(t *testing.T) {
// 沒理由、太短、重複、超過 Threads 短詞規則的 include 都要丟掉 // 沒理由、太短、重複丟掉;過長 include 收成可搜短詞,不再整條丟
svc, _, ctx := suggestService(t, `[ svc, _, ctx := suggestService(t, `[
{"term":"婚攝 求推薦","reason":"在找攝影師的人常這樣問","usage":"include"}, {"term":"婚攝 求推薦","reason":"在找攝影師的人常這樣問","usage":"include"},
{"term":"沒有理由的詞","reason":" ","usage":"include"}, {"term":"沒有理由的詞","reason":" ","usage":"include"},
@ -173,8 +173,13 @@ func TestSuggestDropsUnusableItems(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("suggest: %v", err) t.Fatalf("suggest: %v", err)
} }
if len(list) != 1 || list[0].Term != "婚攝 求推薦" { if len(list) != 2 || list[0].Term != "婚攝 求推薦" || list[1].Term != "台北 婚攝" {
t.Fatalf("got %+v, want only the one usable suggestion", list) t.Fatalf("got %+v, want shortened multi-token plus the original short term", list)
}
for _, item := range list {
if item.Usage == domain.SuggestUsageInclude && !domain.IsThreadsSearchable(item.Term) {
t.Fatalf("include not searchable: %+v", item)
}
} }
} }

View File

@ -59,7 +59,7 @@ func (s *Service) FetchCandidates(ctx context.Context, ownerUID int64, w *domain
if w == nil { if w == nil {
return nil, "", 0, fmt.Errorf("%w: watch required", domain.ErrValidation) return nil, "", 0, fmt.Errorf("%w: watch required", domain.ErrValidation)
} }
terms := w.Terms terms := domain.ExpandSearchTerms(w.Terms, domain.MaxWatchTerms)
if len(terms) == 0 { if len(terms) == 0 {
return nil, "", 0, fmt.Errorf("%w: watch has no terms", domain.ErrValidation) return nil, "", 0, fmt.Errorf("%w: watch has no terms", domain.ErrValidation)
} }
@ -104,11 +104,15 @@ func (s *Service) FetchCandidates(ctx context.Context, ownerUID int64, w *domain
out := make([]*domain.CandidatePost, 0, len(hits)) out := make([]*domain.CandidatePost, 0, len(hits))
for _, h := range hits { for _, h := range hits {
permalink := strings.TrimSpace(h.URL)
text := strings.TrimSpace(h.Snippet) text := strings.TrimSpace(h.Snippet)
if text == "" { if text == "" {
text = strings.TrimSpace(h.Title) text = strings.TrimSpace(h.Title)
} }
if text == "" { if text == "" && permalink != "" {
text = permalink
}
if text == "" || permalink == "" {
continue continue
} }
blob := strings.ToLower(text + " " + h.Title) blob := strings.ToLower(text + " " + h.Title)
@ -122,10 +126,6 @@ func (s *Service) FetchCandidates(ctx context.Context, ownerUID int64, w *domain
if skip { if skip {
continue continue
} }
permalink := strings.TrimSpace(h.URL)
if permalink == "" {
continue
}
term := matchingWatchTerm(blob, terms) term := matchingWatchTerm(blob, terms)
class := classifyCandidate(blob) class := classifyCandidate(blob)
out = append(out, &domain.CandidatePost{ out = append(out, &domain.CandidatePost{

View File

@ -0,0 +1,65 @@
package usecase
import (
"context"
"fmt"
"strings"
"testing"
"apps/backend/internal/module/radar/domain"
)
func TestMergeFetchWatchKeepsSubscriberTerms(t *testing.T) {
w := &domain.RadarWatch{Terms: []string{"求推薦", "泛紅"}, ExcludeTerms: []string{"抽獎"}}
plan := &domain.QueryPlan{Groups: []domain.QueryPlanGroup{
{Query: "換季 不適", Exclude: []string{"業配"}},
{Query: "求推薦"},
}}
got := mergeFetchWatch(w, plan)
if len(got.Terms) != 3 || got.Terms[0] != "求推薦" || got.Terms[1] != "泛紅" || got.Terms[2] != "換季 不適" {
t.Fatalf("terms=%v want subscriber keywords first, then new plan queries", got.Terms)
}
if len(got.ExcludeTerms) != 2 {
t.Fatalf("exclude=%v", got.ExcludeTerms)
}
}
func TestMergeFetchWatchExpandsLongSubscriberTerms(t *testing.T) {
w := &domain.RadarWatch{Terms: []string{"晚上睡覺容易口乾舌燥怎麼辦"}}
got := mergeFetchWatch(w, nil)
if len(got.Terms) == 0 {
t.Fatal("long subscriber term must expand before fetch")
}
for _, term := range got.Terms {
if !domain.IsThreadsSearchable(term) {
t.Fatalf("fetch term %q is not searchable", term)
}
}
}
func TestHumanFetchErrorCrawlerSessionExpired(t *testing.T) {
got := humanFetchError(fmt.Errorf(`Chrome crawler status 422: {"error":"crawler session expired"}`))
if !strings.Contains(got, "Chrome 登入已過期") || !strings.Contains(got, "設定") {
t.Fatalf("human message=%q", got)
}
}
func TestFetchCandidatesKeepsTitleOnlyHits(t *testing.T) {
svc := New(nil)
svc.HitFetch = HitFetcherFunc(func(context.Context, int64, []string, int) ([]ThreadHit, string, error) {
return []ThreadHit{{
URL: "https://www.threads.net/@a/post/xyz",
Title: "皮膚泛紅怎麼辦",
}}, "api", nil
})
// bill path: HitFetch returns api so FetchCandidates will try to bill.
// Avoid billing by setting path after... FetchCandidates bills API path.
// Use empty Usage so bill is a no-op if implemented that way.
cands, _, _, err := svc.FetchCandidates(context.Background(), 1, &domain.RadarWatch{Terms: []string{"泛紅"}}, 10)
if err != nil {
t.Fatalf("fetch: %v", err)
}
if len(cands) != 1 || cands[0].Text == "" {
t.Fatalf("title-only hit dropped: %+v err=%v", cands, err)
}
}

View File

@ -12,6 +12,7 @@ import (
type SweepRunResult struct { type SweepRunResult struct {
Sweep *domain.RadarSweep Sweep *domain.RadarSweep
Created int Created int
Rematched int
Judged int Judged int
Truncated int Truncated int
FailedJudges int FailedJudges int
@ -85,17 +86,10 @@ func (s *Service) RunSweep(ctx context.Context, ownerUID int64, watchID, jobID s
already[id] = true already[id] = true
} }
fetchWatch := w fetchWatch := mergeFetchWatch(w, nil)
if w.ContextMode == domain.WatchContextProduct { if w.ContextMode == domain.WatchContextProduct {
if plan, perr := s.BuildProductQueryPlan(ctx, ownerUID, w.ProductID); perr == nil && plan != nil && len(plan.Groups) > 0 { if plan, perr := s.BuildProductQueryPlan(ctx, ownerUID, w.ProductID); perr == nil && plan != nil && len(plan.Groups) > 0 {
planned := *w fetchWatch = mergeFetchWatch(w, plan)
planned.Terms = make([]string, 0, len(plan.Groups))
planned.ExcludeTerms = append([]string(nil), w.ExcludeTerms...)
for _, group := range plan.Groups {
planned.Terms = append(planned.Terms, group.Query)
planned.ExcludeTerms = append(planned.ExcludeTerms, group.Exclude...)
}
fetchWatch = &planned
} }
} }
cands, path, fetchCredits, ferr := s.FetchCandidates(ctx, ownerUID, fetchWatch, 40) cands, path, fetchCredits, ferr := s.FetchCandidates(ctx, ownerUID, fetchWatch, 40)
@ -135,7 +129,7 @@ func (s *Service) RunSweep(ctx context.Context, ownerUID int64, watchID, jobID s
} }
} }
created, judged, truncated, failed, judgeCredits, perr := s.ProcessCandidates( created, rematched, judged, truncated, failed, judgeCredits, perr := s.ProcessCandidates(
ctx, ownerUID, w, profile, sw.ID, cands, already, ctx, ownerUID, w, profile, sw.ID, cands, already,
) )
if perr != nil { if perr != nil {
@ -162,6 +156,7 @@ func (s *Service) RunSweep(ctx context.Context, ownerUID int64, watchID, jobID s
return &SweepRunResult{ return &SweepRunResult{
Sweep: sw, Sweep: sw,
Created: created, Created: created,
Rematched: rematched,
Judged: judged, Judged: judged,
Truncated: truncated, Truncated: truncated,
FailedJudges: failed, FailedJudges: failed,
@ -199,15 +194,50 @@ func (s *Service) notifySweepFailed(ctx context.Context, ownerUID int64, sweepID
return s.Notifier.NotifySweepFailed(ctx, ownerUID, sweepID, watchID, reason) return s.Notifier.NotifySweepFailed(ctx, ownerUID, sweepID, watchID, reason)
} }
// mergeFetchWatch keeps the subscriber's own keywords and adds demand-map
// queries. Replacing the watch terms with only plan groups made patrols
// miss the phrases the user actually typed.
func mergeFetchWatch(w *domain.RadarWatch, plan *domain.QueryPlan) *domain.RadarWatch {
if w == nil {
return nil
}
planned := *w
terms := append([]string{}, w.Terms...)
excludes := append([]string{}, w.ExcludeTerms...)
seen := map[string]bool{}
for _, t := range terms {
seen[strings.ToLower(strings.TrimSpace(t))] = true
}
if plan != nil {
for _, group := range plan.Groups {
q := strings.TrimSpace(group.Query)
if q != "" && !seen[strings.ToLower(q)] {
terms = append(terms, q)
seen[strings.ToLower(q)] = true
}
excludes = append(excludes, group.Exclude...)
}
}
planned.Terms = domain.ExpandSearchTerms(terms, domain.MaxWatchTerms)
planned.ExcludeTerms = excludes
return &planned
}
func humanFetchError(err error) string { func humanFetchError(err error) string {
if err == nil { if err == nil {
return "抓取失敗" return "抓取失敗"
} }
msg := err.Error() msg := err.Error()
low := strings.ToLower(msg)
// never include token-like blobs // never include token-like blobs
if strings.Contains(strings.ToLower(msg), "bearer ") { if strings.Contains(low, "bearer ") {
return "抓取路徑不可用" return "抓取路徑不可用"
} }
if strings.Contains(low, "crawler session expired") ||
strings.Contains(low, "crawler session is invalid") ||
strings.Contains(low, "crawler session required") {
return "今天沒巡到Chrome 登入已過期。請到設定重新同步已登入的 Threads 分頁,或先關掉開發模式改走 API 搜尋。"
}
if len(msg) > 200 { if len(msg) > 200 {
msg = msg[:200] msg = msg[:200]
} }

View File

@ -18,6 +18,12 @@ type SweepJobScheduler interface {
ScheduleRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (jobID string, err error) ScheduleRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (jobID string, err error)
} }
// ManualSweepJobScheduler is optional. TriggerSweep uses it so 「立即巡邏」
// is not swallowed by a finished same-day daily job.
type ManualSweepJobScheduler interface {
ScheduleManualRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (jobID string, err error)
}
// SweepJobSchedulerFunc adapts a function to SweepJobScheduler. // SweepJobSchedulerFunc adapts a function to SweepJobScheduler.
type SweepJobSchedulerFunc func(ctx context.Context, ownerUID int64, watchID string, runAt int64) (jobID string, err error) type SweepJobSchedulerFunc func(ctx context.Context, ownerUID int64, watchID string, runAt int64) (jobID string, err error)

View File

@ -69,6 +69,25 @@ func TestCreateWatchRejectsBadInput(t *testing.T) {
} }
} }
func TestCreateWatchExpandsUnsearchableTerms(t *testing.T) {
svc, ctx := serviceWithProfile(t, 5)
w, err := svc.CreateWatch(ctx, 42, WatchInput{
Terms: []string{"晚上睡覺容易口乾舌燥怎麼辦"},
Enabled: true,
})
if err != nil {
t.Fatalf("create: %v", err)
}
if len(w.Terms) == 0 {
t.Fatal("long sentence must expand into searchable terms")
}
for _, term := range w.Terms {
if !domain.IsThreadsSearchable(term) {
t.Fatalf("persisted term %q is not searchable", term)
}
}
}
func TestWatchPauseResumeRoundTrip(t *testing.T) { func TestWatchPauseResumeRoundTrip(t *testing.T) {
svc, ctx := serviceWithProfile(t, 5) svc, ctx := serviceWithProfile(t, 5)

View File

@ -7,13 +7,14 @@ import (
) )
var ( var (
ErrNotFound = errors.New("scout not found") ErrNotFound = errors.New("scout not found")
ErrForbidden = errors.New("scout forbidden") ErrForbidden = errors.New("scout forbidden")
ErrValidation = errors.New("scout validation") ErrValidation = errors.New("scout validation")
ErrNoCrawlerSession = errors.New("crawler session required when dev_mode enabled") ErrNoCrawlerSession = errors.New("crawler session required when dev_mode enabled")
ErrTopicRemoved = errors.New("ScoutTopic CRUD removed") ErrCrawlerSessionExpired = errors.New("crawler session expired")
ErrHasProducts = errors.New("brand has products; remove products first") ErrTopicRemoved = errors.New("ScoutTopic CRUD removed")
ErrIllegalRunStatus = errors.New("illegal scout run status transition") ErrHasProducts = errors.New("brand has products; remove products first")
ErrIllegalRunStatus = errors.New("illegal scout run status transition")
) )
func NowNano() int64 { return time.Now().UTC().UnixNano() } func NowNano() int64 { return time.Now().UTC().UnixNano() }

View File

@ -4,11 +4,14 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"strings" "strings"
"time" "time"
"apps/backend/internal/module/scout/domain"
) )
// ChromeCrawlerProvider is the private worker-to-browser boundary. The // ChromeCrawlerProvider is the private worker-to-browser boundary. The
@ -43,7 +46,12 @@ func (p *HTTPCrawlerProvider) ResolveMediaID(ctx context.Context, storageState,
defer res.Body.Close() defer res.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(res.Body, 64<<10)) raw, _ := io.ReadAll(io.LimitReader(res.Body, 64<<10))
if res.StatusCode < 200 || res.StatusCode >= 300 { if res.StatusCode < 200 || res.StatusCode >= 300 {
return "", fmt.Errorf("Chrome resolver status %d: %s", res.StatusCode, truncate(string(raw), 160)) body := truncate(string(raw), 160)
err := fmt.Errorf("Chrome resolver status %d: %s", res.StatusCode, body)
if isCrawlerSessionDead(err) {
return "", fmt.Errorf("%w (%s)", domain.ErrCrawlerSessionExpired, body)
}
return "", err
} }
var out struct { var out struct {
MediaID string `json:"media_id"` MediaID string `json:"media_id"`
@ -104,7 +112,12 @@ func (p *HTTPCrawlerProvider) SearchChrome(ctx context.Context, storageState str
defer res.Body.Close() defer res.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20)) raw, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if res.StatusCode < 200 || res.StatusCode >= 300 { if res.StatusCode < 200 || res.StatusCode >= 300 {
return nil, fmt.Errorf("Chrome crawler status %d: %s", res.StatusCode, truncate(string(raw), 160)) body := truncate(string(raw), 160)
err := fmt.Errorf("Chrome crawler status %d: %s", res.StatusCode, body)
if isCrawlerSessionDead(err) {
return nil, fmt.Errorf("%w (%s)", domain.ErrCrawlerSessionExpired, body)
}
return nil, err
} }
var out struct { var out struct {
Posts []struct { Posts []struct {
@ -165,3 +178,16 @@ func isSoftAged(publishedAtNano int64, softDays int) bool {
cutoff := time.Now().UTC().AddDate(0, 0, -softDays).UnixNano() cutoff := time.Now().UTC().AddDate(0, 0, -softDays).UnixNano()
return publishedAtNano < cutoff return publishedAtNano < cutoff
} }
func isCrawlerSessionDead(err error) bool {
if err == nil {
return false
}
if errors.Is(err, domain.ErrNoCrawlerSession) || errors.Is(err, domain.ErrCrawlerSessionExpired) {
return true
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "crawler session expired") ||
strings.Contains(msg, "crawler session is invalid") ||
strings.Contains(msg, "crawler session required")
}

View File

@ -2,9 +2,11 @@ package usecase
import ( import (
"context" "context"
"fmt"
"testing" "testing"
"apps/backend/internal/module/scout/domain" "apps/backend/internal/module/scout/domain"
"apps/backend/internal/module/scout/repository"
) )
type recordingProvider struct { type recordingProvider struct {
@ -39,8 +41,8 @@ func TestSearchHitsOnlyFansOutPerTermAndDedupes(t *testing.T) {
if path != domain.PathAPI { if path != domain.PathAPI {
t.Fatalf("path=%q want %q", path, domain.PathAPI) t.Fatalf("path=%q want %q", path, domain.PathAPI)
} }
if len(prov.calls) != 2 { if len(prov.calls) < 2 {
t.Fatalf("provider calls=%d want 2 (fan-out), calls=%v", len(prov.calls), prov.calls) t.Fatalf("provider calls=%d want >=2 (fan-out, plus sparse top-up)", len(prov.calls))
} }
for _, c := range prov.calls { for _, c := range prov.calls {
if len(c) != 1 { if len(c) != 1 {
@ -129,6 +131,87 @@ func TestMergeHitsDedupe(t *testing.T) {
} }
} }
type alwaysDevMode struct{}
func (alwaysDevMode) DevModeEnabled(context.Context, int64) (bool, error) { return true, nil }
type expiredCrawler struct{ calls int }
func (c *expiredCrawler) SearchChrome(context.Context, string, []string, int) ([]ThreadSearchResult, error) {
c.calls++
return nil, fmt.Errorf(`Chrome crawler status 422: {"error":"crawler session expired"}`)
}
func (c *expiredCrawler) ResolveMediaID(context.Context, string, string) (string, error) {
return "", fmt.Errorf("crawler session expired")
}
func TestSearchHitsOnlyFallsBackToAPIWhenCrawlerSessionExpired(t *testing.T) {
crawler := &expiredCrawler{}
prov := &recordingProvider{}
svc := &Service{
Provider: prov,
Crawler: crawler,
Settings: alwaysDevMode{},
Repo: repository.NewMemory(),
SessionSecret: "test-crawler-session-secret",
}
if err := svc.SetCrawlerSession(context.Background(), 1, `{"cookies":[{"domain":".threads.net","expires":4102444800}]}`); err != nil {
t.Fatalf("seed session: %v", err)
}
hits, path, err := svc.SearchHitsOnly(context.Background(), 1, []string{"保母", "求推薦"}, 20)
if err != nil {
t.Fatalf("expired crawler must fall back to api: %v", err)
}
if path != domain.PathAPI {
t.Fatalf("path=%q want api after crawler session expired", path)
}
if len(hits) == 0 {
t.Fatal("api fallback returned no hits")
}
if crawler.calls != 1 {
t.Fatalf("dead session should stop crawler fan-out, calls=%d", crawler.calls)
}
if len(prov.calls) == 0 {
t.Fatal("api provider was not used")
}
}
func TestSearchHitsOnlyFallsBackToAPIWhenCrawlerSessionMissing(t *testing.T) {
prov := &recordingProvider{}
svc := &Service{Provider: prov, Settings: alwaysDevMode{}}
hits, path, err := svc.SearchHitsOnly(context.Background(), 1, []string{"保母"}, 10)
if err != nil {
t.Fatalf("missing session must fall back to api: %v", err)
}
if path != domain.PathAPI || len(hits) == 0 {
t.Fatalf("path=%q hits=%d", path, len(hits))
}
}
func TestSearchHitsOnlyErrorsWhenCrawlerExpiredAndAPIUnavailable(t *testing.T) {
crawler := &expiredCrawler{}
svc := &Service{
Crawler: crawler,
Settings: alwaysDevMode{},
Repo: repository.NewMemory(),
SessionSecret: "test-crawler-session-secret",
}
if err := svc.SetCrawlerSession(context.Background(), 1, `{"cookies":[{"domain":".threads.net","expires":4102444800}]}`); err != nil {
t.Fatalf("seed session: %v", err)
}
_, path, err := svc.SearchHitsOnly(context.Background(), 1, []string{"保母"}, 10)
if err == nil {
t.Fatal("both paths down must error")
}
if path != domain.PathCrawler {
t.Fatalf("path=%q want crawler", path)
}
if !isCrawlerSessionDead(err) {
t.Fatalf("err=%v want session-dead", err)
}
}
func TestCanonicalPostIdentityDedupesThreadsURLAliases(t *testing.T) { func TestCanonicalPostIdentityDedupesThreadsURLAliases(t *testing.T) {
aliases := []string{ aliases := []string{
"https://www.threads.net/@alice/post/AbC123?xmt=AQG", "https://www.threads.net/@alice/post/AbC123?xmt=AQG",

View File

@ -138,6 +138,10 @@ func (r *searchPipelineRunner) runStageForTerms(perQuery int, source func(contex
if err != nil { if err != nil {
r.lastErr = err r.lastErr = err
r.diagnostics.SourceUnavailable = true r.diagnostics.SourceUnavailable = true
if isCrawlerSessionDead(err) {
r.source = nil
break
}
} }
if err == nil { if err == nil {
r.sourceOK = true r.sourceOK = true

View File

@ -515,31 +515,49 @@ func (s *Service) SearchHitsOnly(ctx context.Context, ownerUID int64, terms []st
} }
} }
if devMode { if devMode {
storageState, serr := s.GetCrawlerSessionToken(ctx, ownerUID) var storageState string
if serr != nil { var serr error
return nil, domain.PathCrawler, domain.ErrNoCrawlerSession if s.Repo != nil {
storageState, serr = s.GetCrawlerSessionToken(ctx, ownerUID)
} else {
serr = domain.ErrNoCrawlerSession
} }
path = domain.PathCrawler if serr != nil || strings.TrimSpace(storageState) == "" {
if s.Crawler == nil { if s.Provider == nil {
return nil, path, fmt.Errorf("Chrome crawler is not configured") return nil, domain.PathCrawler, domain.ErrNoCrawlerSession
}
logx.Infof("scout SearchHitsOnly: no crawler session uid=%d; falling back to api", ownerUID)
} else if s.Crawler == nil {
if s.Provider == nil {
return nil, domain.PathCrawler, fmt.Errorf("Chrome crawler is not configured")
}
logx.Infof("scout SearchHitsOnly: crawler not configured uid=%d; falling back to api", ownerUID)
} else {
path = domain.PathCrawler
hits, err = fanOutSearch(ctx, terms, perQuery, func(ctx context.Context, q string, n int) ([]ThreadSearchResult, error) {
return s.Crawler.SearchChrome(ctx, storageState, []string{q}, n)
})
if err == nil {
hits = fillSearchHitsToTarget(ctx, s, terms, hits, limit, path, storageState)
return capHits(hits, limit), path, nil
}
if s.Provider == nil {
return nil, path, err
}
logx.Errorf("scout SearchHitsOnly: crawler failed uid=%d: %v; falling back to api", ownerUID, err)
} }
hits, err = fanOutSearch(ctx, terms, perQuery, func(ctx context.Context, q string, n int) ([]ThreadSearchResult, error) {
return s.Crawler.SearchChrome(ctx, storageState, []string{q}, n)
})
if err != nil {
return nil, path, err
}
return capHits(hits, limit), path, nil
} }
if s.Provider == nil { if s.Provider == nil {
return nil, path, fmt.Errorf("scout search provider is not configured") return nil, path, fmt.Errorf("scout search provider is not configured")
} }
path = domain.PathAPI
hits, err = fanOutSearch(ctx, terms, perQuery, func(ctx context.Context, q string, n int) ([]ThreadSearchResult, error) { hits, err = fanOutSearch(ctx, terms, perQuery, func(ctx context.Context, q string, n int) ([]ThreadSearchResult, error) {
return s.Provider.SearchThreads(ctx, []string{q}, n) return s.Provider.SearchThreads(ctx, []string{q}, n)
}) })
if err != nil { if err != nil {
return nil, path, err return nil, path, err
} }
hits = fillSearchHitsToTarget(ctx, s, terms, hits, limit, path, "")
return capHits(hits, limit), path, nil return capHits(hits, limit), path, nil
} }
@ -761,6 +779,9 @@ func fanOutSearch(ctx context.Context, terms []string, perQuery int, search func
if firstErr == nil { if firstErr == nil {
firstErr = err firstErr = err
} }
if isCrawlerSessionDead(err) {
break
}
continue continue
} }
for _, hit := range hits { for _, hit := range hits {

View File

@ -247,14 +247,8 @@ func NewServiceContext(c config.Config) *ServiceContext {
// 商機回覆一鍵送出:同一條 Outbox 佇列+同一套 crawler media id 解析,不重造第二套送出路徑。 // 商機回覆一鍵送出:同一條 Outbox 佇列+同一套 crawler media id 解析,不重造第二套送出路徑。
radarSvc.ReplyQueue = &scoutReplyQueue{Studio: studioSvc} radarSvc.ReplyQueue = &scoutReplyQueue{Studio: studioSvc}
radarSvc.MediaResolver = scoutSvc radarSvc.MediaResolver = scoutSvc
// 每日巡與手動觸發共用 job.ScheduleRadarSweep同 template、同日去重 // 每日巡走同日去重;立即巡邏另開 manual job避免當天已成功的日巡把按鈕吞掉。
radarSvc.SweepJobs = radarUC.SweepJobSchedulerFunc(func(ctx context.Context, ownerUID int64, watchID string, runAt int64) (string, error) { radarSvc.SweepJobs = radarSweepJobs{jobs: jobs}
j, err := jobs.ScheduleRadarSweep(ctx, ownerUID, watchID, runAt)
if err != nil {
return "", err
}
return j.ID, nil
})
zipPath := findExtensionZip() zipPath := findExtensionZip()
@ -552,6 +546,32 @@ func (b *metaMediaBridge) ListProfilePosts(ctx context.Context, accessToken, use
return out, nil return out, nil
} }
type radarSweepJobs struct {
jobs *jobUC.Service
}
func (a radarSweepJobs) ScheduleRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (string, error) {
if a.jobs == nil {
return "", fmt.Errorf("jobs not configured")
}
j, err := a.jobs.ScheduleRadarSweep(ctx, ownerUID, watchID, runAt)
if err != nil {
return "", err
}
return j.ID, nil
}
func (a radarSweepJobs) ScheduleManualRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (string, error) {
if a.jobs == nil {
return "", fmt.Errorf("jobs not configured")
}
j, err := a.jobs.ScheduleManualRadarSweep(ctx, ownerUID, watchID, runAt)
if err != nil {
return "", err
}
return j.ID, nil
}
// personaJobBridge adapts job.Service → studio.PersonaAnalyzeScheduler (jobID only). // personaJobBridge adapts job.Service → studio.PersonaAnalyzeScheduler (jobID only).
type personaJobBridge struct { type personaJobBridge struct {
Jobs *jobUC.Service Jobs *jobUC.Service

View File

@ -87,9 +87,9 @@ export default function App() {
<Route path="outbox" element={<OutboxPage />} /> <Route path="outbox" element={<OutboxPage />} />
<Route path="outbox/:id" element={<OutboxDetailPage />} /> <Route path="outbox/:id" element={<OutboxDetailPage />} />
<Route path="scout" element={<ScoutPage />} /> <Route path="scout" element={<ScoutPage />} />
<Route path="radar" element={<Navigate to="/app/radar/today" replace />} /> <Route path="radar" element={<RadarOpportunitiesPage />} />
<Route path="radar/watches" element={<RadarWatchesPage />} /> <Route path="radar/watches" element={<RadarWatchesPage />} />
<Route path="radar/today" element={<Navigate to="/app/radar/opportunities?review_state=pending&time_scope=today" replace />} /> <Route path="radar/today" element={<RadarOpportunitiesPage />} />
<Route path="radar/opportunities" element={<RadarOpportunitiesPage />} /> <Route path="radar/opportunities" element={<RadarOpportunitiesPage />} />
<Route path="crm" element={<CrmBoardPage />} /> <Route path="crm" element={<CrmBoardPage />} />
<Route path="crm/followups" element={<CrmFollowUpsPage />} /> <Route path="crm/followups" element={<CrmFollowUpsPage />} />

View File

@ -38,15 +38,15 @@ export function OpportunityDetailDrawer({ opportunity, onClose, onAccept, onComp
</section> </section>
{opportunity.reasons.length ? <section className="hb-opp-drawer__section"><h3></h3><div className="hb-opp-reasons">{opportunity.reasons.map((reason) => <div className="hb-opp-reason" key={reason.dimension}><strong>{reason.dimension}</strong><span>{reason.score}</span><span>{reason.reason}</span></div>)}</div></section> : null} {opportunity.reasons.length ? <section className="hb-opp-drawer__section"><h3></h3><div className="hb-opp-reasons">{opportunity.reasons.map((reason) => <div className="hb-opp-reason" key={reason.dimension}><strong>{reason.dimension}</strong><span>{reason.score}</span><span>{reason.reason}</span></div>)}</div></section> : null}
<div className="hb-opp-drawer__actions"> <div className="hb-opp-drawer__actions">
{pending && !accepted && onAccept ? (
<Button type="button" disabled={busy} onClick={() => onAccept(opportunity)}></Button>
) : null}
{pending && onComplete ? ( {pending && onComplete ? (
<Button type="button" variant="secondary" disabled={busy} onClick={() => onComplete(opportunity)}></Button> <Button type="button" disabled={busy} onClick={() => onComplete(opportunity)}></Button>
) : null}
{pending && !accepted && onAccept ? (
<Button type="button" variant="ghost" disabled={busy} onClick={() => onAccept(opportunity)}></Button>
) : null} ) : null}
<a className="hb-btn hb-btn--ghost" href={opportunity.permalink} target="_blank" rel="noreferrer"> Threads </a> <a className="hb-btn hb-btn--ghost" href={opportunity.permalink} target="_blank" rel="noreferrer"> Threads </a>
</div> </div>
{pending ? <p className="hb-field__hint">使</p> : null} {pending ? <p className="hb-field__hint"></p> : null}
</div> </div>
</aside> </aside>
); );

View File

@ -67,14 +67,14 @@ export function OpportunityInboxCard({ opportunity, onOpen, onAccept, onComplete
<a href={opportunity.permalink} target="_blank" rel="noreferrer"> Threads </a> <a href={opportunity.permalink} target="_blank" rel="noreferrer"> Threads </a>
</div> </div>
<div className="hb-opp-card__actions" aria-label="商機操作"> <div className="hb-opp-card__actions" aria-label="商機操作">
{pending ? <Button type="button" variant="primary" disabled={busy} onClick={() => onComplete(opportunity)}></Button> : null}
{pending ? <Button type="button" variant="danger" disabled={busy} onClick={() => setRemoveOpen((value) => !value)}></Button> : null}
<Button type="button" variant="ghost" onClick={() => onOpen(opportunity)}></Button>
{(pending || reviewState === "completed") && !accepted ? ( {(pending || reviewState === "completed") && !accepted ? (
<Button type="button" variant="primary" disabled={busy} onClick={() => onAccept(opportunity)}> <Button type="button" variant="ghost" disabled={busy} onClick={() => onAccept(opportunity)}>
{busy ? "處理中…" : "加入名單並追蹤"} {busy ? "處理中…" : "加入名單(可選)"}
</Button> </Button>
) : null} ) : null}
{pending ? <Button type="button" variant="secondary" disabled={busy} onClick={() => onComplete(opportunity)}></Button> : null}
{pending ? <Button type="button" variant="danger" disabled={busy} onClick={() => setRemoveOpen((value) => !value)}></Button> : null}
<Button type="button" variant="ghost" onClick={() => onOpen(opportunity)}></Button>
{reviewState === "removed" ? <Button type="button" variant="secondary" disabled={busy} onClick={() => onRestore(opportunity)}></Button> : null} {reviewState === "removed" ? <Button type="button" variant="secondary" disabled={busy} onClick={() => onRestore(opportunity)}></Button> : null}
{reviewState === "completed" && accepted && opportunity.contact_id ? ( {reviewState === "completed" && accepted && opportunity.contact_id ? (
<Link className="hb-btn hb-btn--secondary" to={`/app/crm?contact=${encodeURIComponent(opportunity.contact_id)}`}></Link> <Link className="hb-btn hb-btn--secondary" to={`/app/crm?contact=${encodeURIComponent(opportunity.contact_id)}`}></Link>
@ -83,15 +83,15 @@ export function OpportunityInboxCard({ opportunity, onOpen, onAccept, onComplete
</div> </div>
{removeOpen ? ( {removeOpen ? (
<div className="hb-opp-card__remove-form" aria-label="標示為不適合"> <div className="hb-opp-card__remove-form" aria-label="標示為不適合">
<strong></strong> <strong></strong>
<p className="hb-field__hint"></p> <p className="hb-field__hint"></p>
<Select name={`remove-reason-${opportunity.id}`} label="原因" value={reason} onChange={(event) => setReason(event.target.value as OpportunityRemovalReason)}> <Select name={`remove-reason-${opportunity.id}`} label="原因" value={reason} onChange={(event) => setReason(event.target.value as OpportunityRemovalReason)}>
{(Object.keys(reasonLabels) as Array<Exclude<OpportunityRemovalReason, "legacy_unknown">>).map((key) => <option key={key} value={key}>{reasonLabels[key]}</option>)} {(Object.keys(reasonLabels) as Array<Exclude<OpportunityRemovalReason, "legacy_unknown">>).map((key) => <option key={key} value={key}>{reasonLabels[key]}</option>)}
</Select> </Select>
{reason === "other" ? <Textarea name={`remove-note-${opportunity.id}`} label="補充說明" value={note} onChange={(event) => setNote(event.target.value)} /> : null} {reason === "other" ? <Textarea name={`remove-note-${opportunity.id}`} label="補充說明" value={note} onChange={(event) => setNote(event.target.value)} /> : null}
{reason === "duplicate" ? <p className="hb-field__hint"></p> : null} {reason === "duplicate" ? <p className="hb-field__hint"></p> : null}
<div className="hb-opp-card__actions"> <div className="hb-opp-card__actions">
<Button type="button" variant="danger" disabled={busy || (reason === "other" && !note.trim())} onClick={submitRemove}></Button> <Button type="button" variant="danger" disabled={busy || (reason === "other" && !note.trim())} onClick={submitRemove}></Button>
<Button type="button" variant="ghost" onClick={() => setRemoveOpen(false)}></Button> <Button type="button" variant="ghost" onClick={() => setRemoveOpen(false)}></Button>
</div> </div>
</div> </div>

View File

@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { buildQueryPlan } from "./QueryPlanPreview"; import { buildQueryPlan } from "./QueryPlanPreview";
import type { DemandMap } from "../../domain/types"; import type { DemandMap } from "../../domain/types";
import { isThreadsSearchable } from "../../lib/threadsTerm";
const map: DemandMap = { const map: DemandMap = {
product_id: "p1", demand_input_version: "v1", map_version: 2, state: "ready", product_id: "p1", demand_input_version: "v1", map_version: 2, state: "ready",
@ -13,16 +14,32 @@ const map: DemandMap = {
}; };
describe("QueryPlanPreview", () => { describe("QueryPlanPreview", () => {
it("generates at most six groups with pain/context/outcome and exclusions", () => { it("generates at most six searchable groups and keeps exclusions", () => {
const groups = buildQueryPlan(map); const groups = buildQueryPlan(map);
expect(groups).toHaveLength(2); expect(groups.length).toBeGreaterThan(0);
expect(groups[0].terms).toEqual(["漏水", "天花板滴水", "找人處理"]); expect(groups.length).toBeLessThanOrEqual(6);
expect(groups[0].exclusion).toEqual(["徵才"]); expect(groups[0].exclusion).toEqual(["徵才"]);
expect(groups.every((group) => group.terms.length > 0 && group.terms.every((term) => term !== "安心抓漏"))).toBe(true); expect(groups.every((group) => {
const query = group.terms.join(" ");
return group.terms.length > 0 && group.terms.length <= 2 && isThreadsSearchable(query)
&& group.terms.every((term) => term !== "安心抓漏" && isThreadsSearchable(term));
})).toBe(true);
}); });
it("falls back to scenario/outcome when the pain list is empty", () => { it("falls back to scenario/outcome when the pain list is empty", () => {
const groups = buildQueryPlan({ ...map, pain_phrases: [] }); const groups = buildQueryPlan({ ...map, pain_phrases: [] });
expect(groups.map((group) => group.terms[0])).toEqual(["天花板滴水", "找人處理"]); expect(groups.length).toBeGreaterThan(0);
expect(groups.every((group) => isThreadsSearchable(group.terms.join(" ")))).toBe(true);
});
it("shortens long demand sentences into searchable queries", () => {
const groups = buildQueryPlan({
...map,
pain_phrases: [{ text: "晚上睡覺容易口乾舌燥怎麼辦", kind: "pain", origin: "product", enabled: true }],
scenario_phrases: [{ text: "換季日常修護", kind: "scenario", origin: "product", enabled: true }],
});
expect(groups.length).toBeGreaterThan(0);
expect(groups.every((group) => isThreadsSearchable(group.terms.join(" ")))).toBe(true);
expect(groups.some((group) => group.terms.includes("口乾舌燥") || group.terms.includes("晚上睡覺"))).toBe(true);
}); });
}); });

View File

@ -1,33 +1,109 @@
import type { DemandMap } from "../../domain/types"; import type { DemandMap, DemandMapPhrase } from "../../domain/types";
import { Badge } from "../ui"; import { isThreadsSearchable, normalizeSearchTerm, searchableTermVariants } from "../../lib/threadsTerm";
import { Badge, Button } from "../ui";
export type QueryPlanGroup = { terms: string[]; basis: string; exclusion: string[] }; export type QueryPlanGroup = { terms: string[]; basis: string; exclusion: string[] };
/** Deterministic preview used by mock UX; the live planner will keep this shape. */ const MAX_GROUPS = 6;
export function buildQueryPlan(map: DemandMap): QueryPlanGroup[] {
const pain = map.pain_phrases.filter((item) => item.enabled); function expandPhrases(list: DemandMapPhrase[] = []): DemandMapPhrase[] {
const scenario = map.scenario_phrases.filter((item) => item.enabled); const out: DemandMapPhrase[] = [];
const outcomes = map.desired_outcomes.filter((item) => item.enabled); for (const item of list.filter((phrase) => phrase.enabled)) {
const exclusion = map.exclusion_signals.filter((item) => item.enabled).map((item) => item.text); const variants = searchableTermVariants(item.text).slice(0, 3);
const groups = pain.slice(0, 6).map((item, index) => ({ const basis = item.basis_text || item.text;
terms: [item.text, scenario[index % Math.max(1, scenario.length)]?.text, outcomes[index % Math.max(1, outcomes.length)]?.text].filter(Boolean) as string[], for (const text of variants) {
basis: item.basis_text || "產品痛點", out.push({ ...item, text, basis_text: basis });
exclusion, }
})); }
if (groups.length) return groups; return out;
const fallback = [...scenario, ...outcomes].slice(0, 6);
return fallback.map((item) => ({ terms: [item.text], basis: item.basis_text || "產品需求地圖", exclusion }));
} }
export function QueryPlanPreview({ map }: { map: DemandMap }) { /** Deterministic preview aligned with backend BuildQueryPlan: max 2 searchable tokens. */
export function buildQueryPlan(map: DemandMap): QueryPlanGroup[] {
const pain = expandPhrases(map.pain_phrases);
const scenario = expandPhrases(map.scenario_phrases);
const outcomes = expandPhrases(map.desired_outcomes);
const solutions = expandPhrases(map.solution_signals);
const exclusion = (map.exclusion_signals ?? []).filter((item) => item.enabled).map((item) => item.text);
const groups: QueryPlanGroup[] = [];
const seen = new Set<string>();
const add = (...parts: DemandMapPhrase[]) => {
if (groups.length >= MAX_GROUPS) return;
const terms = parts.map((part) => part.text).filter(Boolean);
const query = normalizeSearchTerm(terms.join(" "));
if (!query || !isThreadsSearchable(query) || seen.has(query.toLowerCase())) return;
seen.add(query.toLowerCase());
groups.push({ terms, basis: parts[0]?.basis_text || "產品痛點", exclusion });
};
for (const item of pain) {
add(item);
if (groups.length >= MAX_GROUPS) break;
for (const next of scenario) {
add(item, next);
if (groups.length >= MAX_GROUPS) break;
}
if (groups.length >= MAX_GROUPS) break;
for (const next of outcomes) {
add(item, next);
if (groups.length >= MAX_GROUPS) break;
}
if (groups.length >= MAX_GROUPS) break;
}
if (!groups.length) {
for (const item of scenario) {
for (const next of solutions) {
add(item, next);
if (groups.length >= MAX_GROUPS) break;
}
}
}
if (!groups.length) {
for (const item of [...scenario, ...outcomes, ...solutions]) {
add(item);
if (groups.length >= MAX_GROUPS) break;
}
}
return groups;
}
export function QueryPlanPreview({
map,
onAdoptQueries,
}: {
map: DemandMap;
onAdoptQueries?: (queries: string[]) => void;
}) {
const groups = buildQueryPlan(map); const groups = buildQueryPlan(map);
const queries = groups.map((group) => group.terms.join(" ")).filter(isThreadsSearchable);
return ( return (
<section className="hb-query-plan" aria-label="查詢計畫預覽"> <section className="hb-query-plan" aria-label="查詢計畫預覽">
<div className="hb-query-plan__head"> <div className="hb-query-plan__head">
<div><h4></h4><p className="hb-radar-section__hint"></p></div> <div><h4></h4><p className="hb-radar-section__hint"> 24 Threads </p></div>
<span className="hb-radar-section__hint"> {map.demand_input_version} · v{map.map_version}</span> <span className="hb-radar-section__hint"> {map.demand_input_version} · v{map.map_version}</span>
</div> </div>
{groups.length ? <div className="hb-query-plan__groups">{groups.map((group, index) => <article className="hb-query-plan__group" key={`${group.basis}-${index}`}><strong> {index + 1}</strong><div className="hb-demand-map-editor__basis">{group.terms.map((term) => <Badge key={term} tone="brand">{term}</Badge>)}</div><small>{group.basis}</small>{group.exclusion.length ? <small>{group.exclusion.join("、")}</small> : null}</article>)}</div> : <p className="hb-radar-empty"></p>} {groups.length ? (
<>
<div className="hb-query-plan__groups">
{groups.map((group, index) => (
<article className="hb-query-plan__group" key={`${group.basis}-${index}`}>
<strong> {index + 1}</strong>
<div className="hb-demand-map-editor__basis">
{group.terms.map((term) => <Badge key={term} tone="brand">{term}</Badge>)}
</div>
<small>{group.basis}</small>
{group.exclusion.length ? <small>{group.exclusion.join("、")}</small> : null}
</article>
))}
</div>
{onAdoptQueries && queries.length ? (
<div className="hb-radar-actions">
<Button type="button" variant="secondary" onClick={() => onAdoptQueries(queries)}>
</Button>
</div>
) : null}
</>
) : <p className="hb-radar-empty"></p>}
</section> </section>
); );
} }

View File

@ -1,6 +1,11 @@
import { Link } from "react-router-dom";
import type { RadarSweep } from "../../domain/types"; import type { RadarSweep } from "../../domain/types";
import { Badge } from "../ui"; import { Badge } from "../ui";
function isCrawlerSessionFailure(reason: string): boolean {
return /crawler session|Chrome crawler|Chrome 登入已過期/i.test(reason);
}
const statusLabel: Record<string, string> = { const statusLabel: Record<string, string> = {
complete: "完成", partial_budget: "預算暫停", blocked_budget: "點數不足", failed: "失敗", complete: "完成", partial_budget: "預算暫停", blocked_budget: "點數不足", failed: "失敗",
}; };
@ -17,7 +22,17 @@ export function SweepFunnelSummary({ sweep }: { sweep: RadarSweep }) {
<div className="hb-sweep-funnel__head"><div><h3></h3><p className="hb-radar-section__hint"></p></div><Badge tone={status === "complete" ? "success" : status === "failed" ? "danger" : "warning"}>{statusLabel[status] ?? status}</Badge></div> <div className="hb-sweep-funnel__head"><div><h3></h3><p className="hb-radar-section__hint"></p></div><Badge tone={status === "complete" ? "success" : status === "failed" ? "danger" : "warning"}>{statusLabel[status] ?? status}</Badge></div>
<div className="hb-sweep-funnel__grid">{cells.map(([label, value]) => <div key={label}><span>{label}</span><strong>{value}</strong></div>)}</div> <div className="hb-sweep-funnel__grid">{cells.map(([label, value]) => <div key={label}><span>{label}</span><strong>{value}</strong></div>)}</div>
<div className="hb-sweep-funnel__credits"><span> {sweep.credit_search ?? 0} · {sweep.credit_demand_map ?? 0} · {sweep.credit_judge ?? 0}</span><strong> {sweep.credits_used}</strong></div> <div className="hb-sweep-funnel__credits"><span> {sweep.credit_search ?? 0} · {sweep.credit_demand_map ?? 0} · {sweep.credit_judge ?? 0}</span><strong> {sweep.credits_used}</strong></div>
{sweep.failed_reason ? <p className="hb-banner-error">{sweep.failed_reason}</p> : null} {sweep.failed_reason ? (
<p className="hb-banner-error" role="alert">
{sweep.failed_reason}
{isCrawlerSessionFailure(sweep.failed_reason) ? (
<>
{" "}
<Link to="/app/settings"> Chrome</Link>
</>
) : null}
</p>
) : null}
{status === "partial_budget" || status === "blocked_budget" ? <p className="hb-radar-section__hint">使</p> : null} {status === "partial_budget" || status === "blocked_budget" ? <p className="hb-radar-section__hint">使</p> : null}
</section> </section>
); );

View File

@ -10,6 +10,8 @@ type Props = {
/** 採用一條include 進關鍵字、exclude 進排除詞,由呼叫端決定放哪 */ /** 採用一條include 進關鍵字、exclude 進排除詞,由呼叫端決定放哪 */
onAdopt: (suggestion: WatchTermSuggestion) => void; onAdopt: (suggestion: WatchTermSuggestion) => void;
onAdoptAll: (suggestions: WatchTermSuggestion[]) => void; onAdoptAll: (suggestions: WatchTermSuggestion[]) => void;
/** 採用查詢計畫裡已收成的可搜短詞 */
onAdoptQueries?: (queries: string[]) => void;
/** 已在表單裡的詞(含排除詞),用來標示重複,避免使用者按了沒反應 */ /** 已在表單裡的詞(含排除詞),用來標示重複,避免使用者按了沒反應 */
adopted: string[]; adopted: string[];
context?: { brand_id?: string; product_id?: string }; context?: { brand_id?: string; product_id?: string };
@ -23,7 +25,7 @@ const SUGGEST_LIMIT = 8;
* *
* 使 * 使
*/ */
export function WatchSuggestPanel({ onAdopt, onAdoptAll, adopted, context, demandMap }: Props) { export function WatchSuggestPanel({ onAdopt, onAdoptAll, onAdoptQueries, adopted, context, demandMap }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const repos = useRepos(); const repos = useRepos();
const formatError = useFormatApiError(); const formatError = useFormatApiError();
@ -52,7 +54,7 @@ export function WatchSuggestPanel({ onAdopt, onAdoptAll, adopted, context, deman
<div className="hb-radar-section"> <div className="hb-radar-section">
<h3 className="hb-radar-section__title">{t("radar.suggest.title")}</h3> <h3 className="hb-radar-section__title">{t("radar.suggest.title")}</h3>
<p className="hb-radar-section__hint">{t("radar.suggest.hint")}</p> <p className="hb-radar-section__hint">{t("radar.suggest.hint")}</p>
{demandMap ? <QueryPlanPreview map={demandMap} /> : null} {demandMap ? <QueryPlanPreview map={demandMap} onAdoptQueries={onAdoptQueries} /> : null}
<div className="hb-radar-actions"> <div className="hb-radar-actions">
<Button type="button" variant="secondary" onClick={() => void ask()} disabled={loading}> <Button type="button" variant="secondary" onClick={() => void ask()} disabled={loading}>

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { Brand, BrandProduct, Opportunity } from "../../domain/types"; import type { Brand, BrandProduct, Opportunity } from "../../domain/types";
import { isThreadsSearchable } from "../../lib/threadsTerm";
import { createMockRadarRepo } from "./radarRepo"; import { createMockRadarRepo } from "./radarRepo";
const brand: Brand = { id: "b1", display_name: "品牌一", brief: "", target_audience: "店家" }; const brand: Brand = { id: "b1", display_name: "品牌一", brief: "", target_audience: "店家" };
@ -53,6 +54,26 @@ describe("mock product radar repository", () => {
await expect(repo.resumeWatch("unavailable")).rejects.toMatchObject({ code: 409001 }); await expect(repo.resumeWatch("unavailable")).rejects.toMatchObject({ code: 409001 });
}); });
it("stamps last_matched_at on sweep so older posted hits stay on today", async () => {
const yesterday = Date.now() * 1e6 - 2 * 86_400_000_000_000;
const repo = createMockRadarRepo({
watches: [{
id: "w1", terms: ["敏感肌"], exclude_terms: [], regions: [], status: "active",
created_at: 1, updated_at: 1,
}],
opportunities: [
opportunity("old-hit", yesterday, { created_at: yesterday, watch_id: "w1" }),
],
});
expect((await repo.listOpportunities({ review_state: "pending", time_scope: "today" })).list.map((o) => o.id))
.not.toContain("old-hit");
await repo.triggerWatchSweep("w1");
const after = await repo.listOpportunities({ review_state: "pending", time_scope: "today" });
expect(after.list.map((o) => o.id)).toContain("old-hit");
expect(after.list.find((o) => o.id === "old-hit")).toMatchObject({ review_state: "pending" });
expect(after.list.find((o) => o.id === "old-hit")?.last_matched_at).toBeGreaterThan(yesterday);
});
it("maps legacy status and supports an idempotent review-state lifecycle", async () => { it("maps legacy status and supports an idempotent review-state lifecycle", async () => {
const repo = createMockRadarRepo({ const repo = createMockRadarRepo({
opportunities: [ opportunities: [
@ -121,4 +142,22 @@ describe("mock product radar repository", () => {
expect(updated.custom_phrases[0].text).toBe("晚上漏水"); expect(updated.custom_phrases[0].text).toBe("晚上漏水");
await expect(repo.updateDemandMap("p1", { ...updated, expected_map_version: 1 })).rejects.toMatchObject({ code: 409002 }); await expect(repo.updateDemandMap("p1", { ...updated, expected_map_version: 1 })).rejects.toMatchObject({ code: 409002 });
}); });
it("shortens long product sentences into searchable demand phrases and suggestions", async () => {
const longProduct: BrandProduct = {
...product,
id: "p-long",
product_context: "換季日常修護",
pain_points: ["晚上睡覺容易口乾舌燥怎麼辦"],
match_tags: ["求推薦"],
provider_capability_terms: ["保濕"],
};
const repo = createMockRadarRepo({ brands: [brand], products: [longProduct] });
const baseline = await repo.getDemandMap("p-long");
expect(baseline.pain_phrases.every((item) => item.text.length <= 4)).toBe(true);
expect(baseline.pain_phrases.some((item) => item.text === "口乾舌燥" || item.text === "晚上睡覺")).toBe(true);
const suggestions = await repo.suggestWatchTerms({ product_id: "p-long", limit: 8 });
expect(suggestions.length).toBeGreaterThan(0);
expect(suggestions.filter((item) => item.usage === "include").every((item) => isThreadsSearchable(item.term))).toBe(true);
});
}); });

View File

@ -11,10 +11,12 @@ import type {
RadarSweep, RadarSweep,
RadarWatch, RadarWatch,
ServiceProfile, ServiceProfile,
WatchTermSuggestion,
} from "../../domain/types"; } from "../../domain/types";
import type { RadarRepo } from "../repos"; import type { RadarRepo } from "../repos";
import { ApiError } from "../live/http"; import { ApiError } from "../live/http";
import { productRadarSeed } from "../fixtures/radarProduct"; import { productRadarSeed } from "../fixtures/radarProduct";
import { expandIncludeTerms, searchableTermVariants } from "../../lib/threadsTerm";
type MockRadarSeed = { type MockRadarSeed = {
brands?: Brand[]; brands?: Brand[];
@ -41,12 +43,28 @@ function phrase(text: string, kind: string, origin: DemandMapPhrase["origin"] =
return { text, kind, origin, basis_kind: origin === "product" ? kind : "custom", basis_text: basisText, enabled: true }; return { text, kind, origin, basis_kind: origin === "product" ? kind : "custom", basis_text: basisText, enabled: true };
} }
function searchablePhrases(items: string[], kind: string, basisLabel: string, include: boolean): DemandMapPhrase[] {
const out: DemandMapPhrase[] = [];
for (const item of items) {
const raw = item.trim();
if (!raw) continue;
if (!include) {
out.push(phrase(raw, kind, "product", basisLabel));
continue;
}
for (const text of searchableTermVariants(raw).slice(0, 3)) {
out.push(phrase(text, kind, "product", raw));
}
}
return out;
}
function baselineDemandMap(product: BrandProduct): DemandMap { function baselineDemandMap(product: BrandProduct): DemandMap {
const pain = product.pain_points.map((text) => phrase(text, "pain", "product", "產品痛點")); const pain = searchablePhrases(product.pain_points, "pain", "產品痛點", true);
const scenario = product.product_context.trim() ? [phrase(product.product_context.trim(), "scenario", "product", "產品情境")] : []; const scenario = searchablePhrases(product.product_context.trim() ? [product.product_context.trim()] : [], "scenario", "產品情境", true);
const outcome = product.match_tags.map((text) => phrase(text, "outcome", "product", "產品標籤")); const outcome = searchablePhrases(product.match_tags, "outcome", "產品標籤", true);
const solution = product.provider_capability_terms.map((text) => phrase(text, "solution", "product", "服務能力")); const solution = searchablePhrases(product.provider_capability_terms, "solution", "產品能力", true);
const exclusion = product.provider_exclude_terms.map((text) => phrase(text, "exclusion", "product", "排除詞")); const exclusion = searchablePhrases(product.provider_exclude_terms, "exclusion", "排除詞", false);
const ready = pain.length > 0 && scenario.length > 0 && solution.length > 0; const ready = pain.length > 0 && scenario.length > 0 && solution.length > 0;
return { return {
product_id: product.id, product_id: product.id,
@ -173,11 +191,13 @@ export function createMockRadarRepo(seed: MockRadarSeed = {}): RadarRepo {
if (productMode && (!input.brand_id || !input.product_id)) error("brand_id and product_id must be provided together"); if (productMode && (!input.brand_id || !input.product_id)) error("brand_id and product_id must be provided together");
if (productMode) productFor(input.brand_id!, input.product_id!); if (productMode) productFor(input.brand_id!, input.product_id!);
if (!productMode && input.enabled !== false && !profile.exists) error("service profile required", 400100); if (!productMode && input.enabled !== false && !profile.exists) error("service profile required", 400100);
const terms = expandIncludeTerms(input.terms);
if (!terms.length) error("terms required");
const id = `watch-${watches.size + 1}`; const id = `watch-${watches.size + 1}`;
const now = nanoNow(); const now = nanoNow();
const row: RadarWatch = { const row: RadarWatch = {
id, id,
terms: copy(input.terms), terms,
exclude_terms: copy(input.exclude_terms ?? []), exclude_terms: copy(input.exclude_terms ?? []),
regions: copy(input.regions ?? []), regions: copy(input.regions ?? []),
status: input.enabled === false ? "paused" : "active", status: input.enabled === false ? "paused" : "active",
@ -211,7 +231,12 @@ export function createMockRadarRepo(seed: MockRadarSeed = {}): RadarRepo {
}, },
async updateWatch(id, patch) { async updateWatch(id, patch) {
const row = watch(id); const row = watch(id);
Object.assign(row, copy(patch), { updated_at: nanoNow() }); const next = { ...copy(patch) };
if (patch.terms) {
next.terms = expandIncludeTerms(patch.terms);
if (!next.terms.length) error("terms required");
}
Object.assign(row, next, { updated_at: nanoNow() });
return copy(row); return copy(row);
}, },
async pauseWatch(id) { async pauseWatch(id) {
@ -239,8 +264,44 @@ export function createMockRadarRepo(seed: MockRadarSeed = {}): RadarRepo {
if (row.status !== "archived") error("only archived watch can be permanently deleted", 400100); if (row.status !== "archived") error("only archived watch can be permanently deleted", 400100);
watches.delete(id); watches.delete(id);
}, },
async suggestWatchTerms() { async suggestWatchTerms(input) {
return []; const parsed = typeof input === "number" ? { limit: input } : input ?? {};
const limit = parsed.limit ?? 8;
const product = parsed.product_id ? products.get(parsed.product_id) : undefined;
if (!product) return [];
const fields: Array<{ kind: WatchTermSuggestion["basis_kind"]; label: string; terms: string[]; usage: WatchTermSuggestion["usage"] }> = [
{ kind: "pain", label: "痛點", terms: product.pain_points, usage: "include" },
{ kind: "tag", label: "標籤", terms: product.match_tags, usage: "include" },
{ kind: "capability", label: "能力", terms: product.provider_capability_terms, usage: "include" },
{ kind: "audience", label: "受眾", terms: [], usage: "include" },
{ kind: "context", label: "情境", terms: product.product_context.trim() ? [product.product_context.trim()] : [], usage: "include" },
{ kind: "exclude", label: "排除詞", terms: product.provider_exclude_terms, usage: "exclude" },
];
const out: WatchTermSuggestion[] = [];
const seen = new Set<string>();
for (const field of fields) {
for (const basis of field.terms) {
const raw = basis.trim();
if (!raw) continue;
const variants = field.usage === "include" ? searchableTermVariants(raw) : [raw];
for (const term of variants) {
const key = `${field.usage}:${term.toLowerCase()}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({
term,
reason: field.usage === "exclude"
? `產品設定將「${raw}」列為排除詞,避免混入非目標貼文`
: `依產品設定的${field.label}${raw}」,可找相關需求貼文`,
usage: field.usage,
basis_kind: field.kind,
basis_text: raw,
});
if (out.length >= limit) return out;
}
}
}
return out;
}, },
async triggerWatchSweep(id) { async triggerWatchSweep(id) {
const row = watch(id); const row = watch(id);
@ -280,6 +341,13 @@ export function createMockRadarRepo(seed: MockRadarSeed = {}): RadarRepo {
sweeps.set(sweep.id, sweep); sweeps.set(sweep.id, sweep);
row.last_swept_at = ended; row.last_swept_at = ended;
row.updated_at = ended; row.updated_at = ended;
for (const opportunity of candidates) {
opportunity.last_matched_at = ended;
const state = reviewState(opportunity);
if (state !== "removed" && state !== "completed") {
opportunity.review_state = "pending";
}
}
return { job_id: sweep.job_id!, sweep_id: sweep.id }; return { job_id: sweep.job_id!, sweep_id: sweep.id };
}, },
async getToday(filter) { async getToday(filter) {
@ -297,9 +365,11 @@ export function createMockRadarRepo(seed: MockRadarSeed = {}): RadarRepo {
if (filter.time_scope === "all" || !filter.time_scope) return true; if (filter.time_scope === "all" || !filter.time_scope) return true;
const now = Date.now() * 1_000_000; const now = Date.now() * 1_000_000;
const start = filter.time_scope === "today" const start = filter.time_scope === "today"
? new Date().setUTCHours(0, 0, 0, 0) * 1_000_000 ? new Date(new Date().setHours(0, 0, 0, 0)).getTime() * 1_000_000
: now - 7 * DAY_NS; : now - 7 * DAY_NS;
return o.posted_at >= start; const posted = o.posted_at ?? 0;
const discovered = o.last_matched_at || o.created_at || 0;
return posted >= start || discovered >= start;
}) })
.sort((a, b) => { .sort((a, b) => {
switch (filter.sort) { switch (filter.sort) {

View File

@ -104,19 +104,19 @@ export const zhTW: MessageDict = {
"help.page.scout.step3": "寫草稿、開 Threads 回覆、標記完成。", "help.page.scout.step3": "寫草稿、開 Threads 回覆、標記完成。",
"help.page.scout.tips": "話題=內容靈感;商機=找需求客戶。找客戶請用商機頁,不要在這裡掃痛點。", "help.page.scout.tips": "話題=內容靈感;商機=找需求客戶。找客戶請用商機頁,不要在這裡掃痛點。",
"help.page.radar_today.title": "今日商機(自動名單)", "help.page.radar_today.title": "商機巡邏",
"help.page.radar_today.what": "依產品痛點與需求意圖排序的工作收件匣;每張卡只需決定加入名單、只標示已處理,或標示不適合。", "help.page.radar_today.what": "定期或立刻巡邏,找出產品能解決的痛點或新文章。看懂理由後留下或丟掉即可。",
"help.page.radar_today.step1": "先讀貼文與「為什麼推薦」,預設由最值得跟進的結果開始。", "help.page.radar_today.step1": "看頁頂巡邏狀態:每日定時是否開著、上次何時巡、要不要立即再巡一輪。",
"help.page.radar_today.step2": "值得跟進按「加入名單並追蹤」;已自行看完按「只標示已處理」。", "help.page.radar_today.step2": "讀「為什麼推薦」。對得上就「留下」,不是你的就「丟掉」。",
"help.page.radar_today.step3": "不是你的客戶按「不適合」並選原因;需要時可從已移除還原。", "help.page.radar_today.step3": "只有真的要追這個人時才「加入名單」。名單不是看結果的必要步驟。",
"help.page.radar_today.tips": "找客戶=這裡(訂閱/立即探索/匯入)。找內容話題用側欄「話題」。", "help.page.radar_today.tips": "立即巡邏與每日定時可同時開著。關掉其中一個不會藏掉另一個。",
"help.page.radar_watches.title": "商機訂閱", "help.page.radar_watches.title": "設定巡邏",
"help.page.radar_watches.what": "設定常駐關鍵字後,系統每日自動巡並寫入今日商機。這不是海巡的「按一次掃一次」。", "help.page.radar_watches.what": "選定產品與關鍵字後,每日定時巡邏會自動跑;也可隨時按立即巡邏。結果回到側欄「商機」。",
"help.page.radar_watches.step1": "先到「商機政策」填服務與判定資料(沒填不能啟用)。", "help.page.radar_watches.step1": "選品牌與產品,補需求地圖裡的痛點。",
"help.page.radar_watches.step2": "新增訂閱:客人會搜的關鍵字、排除詞、可選地區。", "help.page.radar_watches.step2": "填客人會搜的關鍵字與排除詞。",
"help.page.radar_watches.step3": "啟用後等每日排程,或按「立即巡」補一輪。", "help.page.radar_watches.step3": "打開每日定時,或按「立即巡邏」現在跑一輪。",
"help.page.radar_watches.tips": "啟用數有方案上限;滿了要先暫停或封存一組。", "help.page.radar_watches.tips": "啟用數有方案上限;滿了要先暫停一組。",
"help.page.crm_board.title": "名單管理", "help.page.crm_board.title": "名單管理",
"help.page.crm_board.what": "從「今日商機」加入後的聯絡人工作清單,可搜尋、篩選、排序、備註、成交與查看時間軸。", "help.page.crm_board.what": "從「今日商機」加入後的聯絡人工作清單,可搜尋、篩選、排序、備註、成交與查看時間軸。",
@ -972,6 +972,7 @@ export const zhTW: MessageDict = {
"jobs.template.personaAnalyzeText": "人設分析 · 文字來源", "jobs.template.personaAnalyzeText": "人設分析 · 文字來源",
"jobs.template.composeMimic": "仿寫貼文", "jobs.template.composeMimic": "仿寫貼文",
"jobs.template.playGenerateScript": "劇本一次產全文", "jobs.template.playGenerateScript": "劇本一次產全文",
"jobs.template.radarSweep": "商機巡邏",
"jobs.template.unknown": "其他任務", "jobs.template.unknown": "其他任務",
"jobs.stripMore": "還有 {n} 個進行中…", "jobs.stripMore": "還有 {n} 個進行中…",
"jobs.nextRun": "下次執行:{time}", "jobs.nextRun": "下次執行:{time}",
@ -2197,9 +2198,10 @@ export const zhTW: MessageDict = {
"radar.watches.editTitle": "編輯商機訂閱", "radar.watches.editTitle": "編輯商機訂閱",
"radar.watches.requiredHint": "為必填欄位", "radar.watches.requiredHint": "為必填欄位",
"radar.watches.terms": "關鍵字", "radar.watches.terms": "關鍵字",
"radar.watches.termsHint": "一行一個,也可用逗號分隔。客人會搜的說法,命中後進意向判定。", "radar.watches.termsHint": "一行一個短詞。每組最多 2 詞、中文每詞 24 字,才能在 Threads 搜到;長句儲存時會自動收成短詞。",
"radar.watches.threadsWarn": "有關鍵字不合 Threads 短詞規則:每組最多 2 詞、中文每詞 24 字、整組 ≤12 字、勿用標點/#emoji否則常搜不到。", "radar.watches.threadsWarn": "有關鍵字不合 Threads 短詞規則:每組最多 2 詞、中文每詞 24 字、整組 ≤12 字、勿用標點/#emoji。儲存時會自動收成可搜短詞。",
"radar.watches.termsPh": "推薦室內設計\n找設計師", "radar.watches.threadsRequired": "這些關鍵字收不成 Threads 可搜的短詞。請改成每組最多 2 詞、中文每詞 24 字。",
"radar.watches.termsPh": "室內設計\n找設計師",
"radar.watches.excludeTerms": "排除詞", "radar.watches.excludeTerms": "排除詞",
"radar.watches.excludeHint": "命中這些字就整筆跳過,例如同業自我推銷、抽獎文。", "radar.watches.excludeHint": "命中這些字就整筆跳過,例如同業自我推銷、抽獎文。",
"radar.watches.excludePh": "徵才\n抽獎", "radar.watches.excludePh": "徵才\n抽獎",
@ -2228,12 +2230,12 @@ export const zhTW: MessageDict = {
"radar.watches.lastSwept": "上次巡:{at}", "radar.watches.lastSwept": "上次巡:{at}",
"radar.watches.neverSwept": "還沒巡過", "radar.watches.neverSwept": "還沒巡過",
"radar.watches.empty": "還沒有商機訂閱", "radar.watches.empty": "還沒有商機訂閱",
"radar.watches.emptyHint": "加客人會用的說法(例如「推薦室內設計」),系統會每天自動幫你巡。若要現在手動掃痛點/話題,用側欄「海巡」。", "radar.watches.emptyHint": "加客人會用的短詞(例如「室內設計」「找設計師」),系統會每天自動幫你巡。若要現在手動掃痛點/話題,用側欄「海巡」。",
"radar.watches.emptyFiltered": "這個狀態下沒有訂閱", "radar.watches.emptyFiltered": "這個狀態下沒有訂閱",
"radar.watches.scheduleTitle": "每日自動排程UTC 22:00台北隔日 06:00", "radar.watches.scheduleTitle": "每日定時巡邏:每天台北 06:00UTC 22:00",
"radar.watches.scheduleHint": "啟用中的訂閱會在每天排程建立巡邏任務;想現在跑一次,請到該列按「立即補巡」。排程時間目前固定,不能個別設定。", "radar.watches.scheduleHint": "開著的訂閱每天自動巡一輪。要現在看結果,按「立即巡邏」。關掉立即巡邏不會停每日定時。",
"radar.watches.openToday": "查看今日商機", "radar.watches.openToday": "回商機結果",
"radar.watches.sweepNow": "立即巡", "radar.watches.sweepNow": "立即",
"radar.watches.sweepQueued": "已排入商機巡檢", "radar.watches.sweepQueued": "已排入商機巡檢",
"radar.watches.sweepStarted": "商機巡檢已開始(任務 {job}…)", "radar.watches.sweepStarted": "商機巡檢已開始(任務 {job}…)",
@ -2324,7 +2326,7 @@ export const zhTW: MessageDict = {
"radar.today.empty.reason.no_profile": "還沒有服務檔案,無法判定需求適不適合你。", "radar.today.empty.reason.no_profile": "還沒有服務檔案,無法判定需求適不適合你。",
"radar.today.empty.reason.no_watch": "還沒有商機訂閱;建立關鍵字後才會每天自動巡(不是海巡那一輪手動掃)。", "radar.today.empty.reason.no_watch": "還沒有商機訂閱;建立關鍵字後才會每天自動巡(不是海巡那一輪手動掃)。",
"radar.today.empty.reason.all_watches_paused": "訂閱都暫停了,恢復一組才會繼續自動巡。", "radar.today.empty.reason.all_watches_paused": "訂閱都暫停了,恢復一組才會繼續自動巡。",
"radar.today.empty.reason.not_swept_yet": "今日自動巡還沒跑完,也可在商機訂閱頁按「立即補巡」。", "radar.today.empty.reason.not_swept_yet": "每日定時還沒跑完,也可在商機頁按「立即巡邏」。",
"radar.today.empty.reason.sweep_failed": "這輪自動巡失敗,請到商機訂閱頁查看或重試。", "radar.today.empty.reason.sweep_failed": "這輪自動巡失敗,請到商機訂閱頁查看或重試。",
"radar.today.empty.reason.no_hit": "有巡但沒有符合的需求,可放寬關鍵字或排除詞。", "radar.today.empty.reason.no_hit": "有巡但沒有符合的需求,可放寬關鍵字或排除詞。",
"radar.today.msg.accepted": "已加入名單", "radar.today.msg.accepted": "已加入名單",
@ -2585,19 +2587,19 @@ export const en: MessageDict = {
"help.page.scout.step3": "Draft, open Threads to reply, mark done.", "help.page.scout.step3": "Draft, open Threads to reply, mark done.",
"help.page.scout.tips": "Topics = content ideas. Demand = finding customers. Use the Demand page for leads.", "help.page.scout.tips": "Topics = content ideas. Demand = finding customers. Use the Demand page for leads.",
"help.page.radar_today.title": "Today's demand (auto list)", "help.page.radar_today.title": "Demand patrol",
"help.page.radar_today.what": "A work inbox ranked by product pain fit and demand intent. Decide whether to track, mark handled, or remove each result.", "help.page.radar_today.what": "Run a scheduled or immediate patrol to find pains your product can solve, or new posts. Keep or discard after you read the reason.",
"help.page.radar_today.step1": "Read the post and Why recommended; the best opportunities come first by default.", "help.page.radar_today.step1": "Check the patrol desk: whether daily patrol is on, when it last ran, and run one now.",
"help.page.radar_today.step2": "Use Add to contacts and track for a real lead, or Mark handled if no follow-up is needed.", "help.page.radar_today.step2": "Read why it was recommended. Keep a fit, discard the rest.",
"help.page.radar_today.step3": "Use Not a fit with a reason for irrelevant results; removed items remain restorable.", "help.page.radar_today.step3": "Add to contacts only if you want to follow that person. Contacts are optional.",
"help.page.radar_today.tips": "Finding customers = this page (watches / Explore / import). Content topics live under Topics.", "help.page.radar_today.tips": "Immediate and daily patrol can both stay on. Turning one off does not hide the other.",
"help.page.radar_watches.title": "Demand watches", "help.page.radar_watches.title": "Patrol setup",
"help.page.radar_watches.what": "Always-on keywords swept daily into Todays demand. This is not Patrols “run once” scan.", "help.page.radar_watches.what": "Pick a product and keywords. Daily patrol runs on a schedule; you can also run one immediately. Results land on Demand.",
"help.page.radar_watches.step1": "Fill services and qualification rules under Opportunity policy first.", "help.page.radar_watches.step1": "Choose brand and product, then fill the pain map.",
"help.page.radar_watches.step2": "Add a watch: terms buyers type, excludes, optional regions.", "help.page.radar_watches.step2": "Add terms buyers type and excludes.",
"help.page.radar_watches.step3": "Stay active for the daily job, or hit “Sweep now” for an extra pass.", "help.page.radar_watches.step3": "Leave daily patrol on, or hit Run now.",
"help.page.radar_watches.tips": "Active slots are plan-capped; pause or archive to free one.", "help.page.radar_watches.tips": "Active slots are plan-capped; pause one to free a slot.",
"help.page.crm_board.title": "Contact management", "help.page.crm_board.title": "Contact management",
"help.page.crm_board.what": "A searchable, filterable work list of contacts accepted from Todays demand, with notes, wins, and timelines.", "help.page.crm_board.what": "A searchable, filterable work list of contacts accepted from Todays demand, with notes, wins, and timelines.",
@ -3452,6 +3454,7 @@ export const en: MessageDict = {
"jobs.template.personaAnalyzeText": "Persona analyze · from text", "jobs.template.personaAnalyzeText": "Persona analyze · from text",
"jobs.template.composeMimic": "Mimic post", "jobs.template.composeMimic": "Mimic post",
"jobs.template.playGenerateScript": "Play full-script AI", "jobs.template.playGenerateScript": "Play full-script AI",
"jobs.template.radarSweep": "Demand patrol",
"jobs.template.unknown": "Other job", "jobs.template.unknown": "Other job",
"jobs.stripMore": "+{n} more running…", "jobs.stripMore": "+{n} more running…",
"jobs.nextRun": "Next run: {time}", "jobs.nextRun": "Next run: {time}",
@ -4681,9 +4684,10 @@ export const en: MessageDict = {
"radar.watches.editTitle": "Edit demand watch", "radar.watches.editTitle": "Edit demand watch",
"radar.watches.requiredHint": "Required field", "radar.watches.requiredHint": "Required field",
"radar.watches.terms": "Terms", "radar.watches.terms": "Terms",
"radar.watches.termsHint": "One per line, commas work too. Phrases buyers type; hits go to intent scoring.", "radar.watches.termsHint": "One short query per line. Max 2 tokens, CJK 24 chars each — long sentences are shortened on save so Threads can search them.",
"radar.watches.threadsWarn": "Some terms break Threads short-query rules: max 2 words, CJK 24 chars each, ≤12 chars total, no punctuation/#/emoji — long queries often return nothing.", "radar.watches.threadsWarn": "Some terms break Threads short-query rules: max 2 words, CJK 24 chars each, ≤12 chars total, no punctuation/#/emoji. Save shortens them into searchable queries.",
"radar.watches.termsPh": "interior designer recommendation\nlooking for a designer", "radar.watches.threadsRequired": "These terms cannot be shortened into Threads-searchable queries. Use at most 2 tokens, CJK 24 chars each.",
"radar.watches.termsPh": "interior design\nfind designer",
"radar.watches.excludeTerms": "Exclude terms", "radar.watches.excludeTerms": "Exclude terms",
"radar.watches.excludeHint": "A hit here skips the post entirely, e.g. job ads or giveaways.", "radar.watches.excludeHint": "A hit here skips the post entirely, e.g. job ads or giveaways.",
"radar.watches.excludePh": "hiring\ngiveaway", "radar.watches.excludePh": "hiring\ngiveaway",
@ -4714,12 +4718,12 @@ export const en: MessageDict = {
"radar.watches.neverSwept": "Never swept", "radar.watches.neverSwept": "Never swept",
"radar.watches.empty": "No demand watches yet", "radar.watches.empty": "No demand watches yet",
"radar.watches.emptyHint": "radar.watches.emptyHint":
"Add phrases buyers type (e.g. “looking for a designer”); the system sweeps daily. For a one-off pain/topic sortie, use Patrol.", "Add short buyer phrases (e.g. “find designer”); the system sweeps daily. For a one-off pain/topic sortie, use Patrol.",
"radar.watches.emptyFiltered": "No watches in this status", "radar.watches.emptyFiltered": "No watches in this status",
"radar.watches.scheduleTitle": "Daily schedule: 22:00 UTC (06:00 next day Taipei)", "radar.watches.scheduleTitle": "Daily patrol: 06:00 Taipei (22:00 UTC)",
"radar.watches.scheduleHint": "Active watches create a patrol job at the daily schedule. To run one now, use “Sweep now” on that watch. The time is currently fixed and cannot be customized per watch.", "radar.watches.scheduleHint": "Active watches run once a day. Use Run now for an extra pass. Turning off Run now does not stop the daily patrol.",
"radar.watches.openToday": "Open today's demand", "radar.watches.openToday": "Back to findings",
"radar.watches.sweepNow": "Sweep now", "radar.watches.sweepNow": "Run now",
"radar.watches.sweepQueued": "Demand sweep queued", "radar.watches.sweepQueued": "Demand sweep queued",
"radar.watches.sweepStarted": "Demand sweep started (job {job}…)", "radar.watches.sweepStarted": "Demand sweep started (job {job}…)",
@ -4810,7 +4814,7 @@ export const en: MessageDict = {
"radar.today.empty.reason.no_profile": "No service profile yet — fit cannot be scored.", "radar.today.empty.reason.no_profile": "No service profile yet — fit cannot be scored.",
"radar.today.empty.reason.no_watch": "No demand watches yet; create keywords for daily auto sweeps (not Patrols manual scan).", "radar.today.empty.reason.no_watch": "No demand watches yet; create keywords for daily auto sweeps (not Patrols manual scan).",
"radar.today.empty.reason.all_watches_paused": "All watches are paused. Resume one to keep daily sweeps.", "radar.today.empty.reason.all_watches_paused": "All watches are paused. Resume one to keep daily sweeps.",
"radar.today.empty.reason.not_swept_yet": "Todays auto sweep isnt done; you can also hit Sweep now on watches.", "radar.today.empty.reason.not_swept_yet": "Daily patrol hasnt finished yet; you can also hit Run now on Demand.",
"radar.today.empty.reason.sweep_failed": "This auto sweep failed — check demand watches and retry.", "radar.today.empty.reason.sweep_failed": "This auto sweep failed — check demand watches and retry.",
"radar.today.empty.reason.no_hit": "Swept but no matching demand. Loosen terms or exclusions.", "radar.today.empty.reason.no_hit": "Swept but no matching demand. Loosen terms or exclusions.",
"radar.today.msg.accepted": "Added to CRM", "radar.today.msg.accepted": "Added to CRM",

View File

@ -18,6 +18,8 @@ export function jobTemplateLabel(
return t("jobs.template.composeMimic"); return t("jobs.template.composeMimic");
case "play_generate_script": case "play_generate_script":
return t("jobs.template.playGenerateScript"); return t("jobs.template.playGenerateScript");
case "radar_sweep":
return t("jobs.template.radarSweep");
default: default:
return templateType || t("jobs.template.unknown"); return templateType || t("jobs.template.unknown");
} }

View File

@ -32,7 +32,7 @@ export const primaryNav: NavItem[] = [
/** 話題靈感(原海巡活躍話題);找需求請用商機頁「立即探索」 */ /** 話題靈感(原海巡活躍話題);找需求請用商機頁「立即探索」 */
{ key: "scout", path: "/app/scout", labelKey: "nav.scout", label: "話題", en: "Topics" }, { key: "scout", path: "/app/scout", labelKey: "nav.scout", label: "話題", en: "Topics" },
/** 側欄用「商機」:每日自動名單+立即探索+手動匯入 */ /** 側欄用「商機」:每日自動名單+立即探索+手動匯入 */
{ key: "radar", path: "/app/radar/today", labelKey: "nav.radar", label: "商機", en: "Demand" }, { key: "radar", path: "/app/radar", labelKey: "nav.radar", label: "商機", en: "Demand" },
{ key: "crm", path: "/app/crm", labelKey: "nav.crm", label: "名單", en: "CRM" }, { key: "crm", path: "/app/crm", labelKey: "nav.crm", label: "名單", en: "CRM" },
{ key: "outbox", path: "/app/outbox", labelKey: "nav.outbox", label: "發送", en: "Outbox" }, { key: "outbox", path: "/app/outbox", labelKey: "nav.outbox", label: "發送", en: "Outbox" },
{ key: "jobs", path: "/app/jobs", labelKey: "nav.jobs", label: "任務", en: "Jobs" }, { key: "jobs", path: "/app/jobs", labelKey: "nav.jobs", label: "任務", en: "Jobs" },
@ -145,9 +145,9 @@ export function pathForNotification(n: {
if (n.ref_type === "contact" && n.ref_id) return `/app/crm?contact=${encodeURIComponent(n.ref_id)}`; if (n.ref_type === "contact" && n.ref_id) return `/app/crm?contact=${encodeURIComponent(n.ref_id)}`;
if (n.ref_type === "contact") return "/app/crm/followups"; if (n.ref_type === "contact") return "/app/crm/followups";
if (n.ref_type === "followup" || n.ref_type === "follow_up") return "/app/crm/followups"; if (n.ref_type === "followup" || n.ref_type === "follow_up") return "/app/crm/followups";
if (n.ref_type === "opportunity" && n.ref_id) return "/app/radar/today"; if (n.ref_type === "opportunity" && n.ref_id) return "/app/radar";
if (n.ref_type === "sweep" || n.ref_type === "radar_watch") return "/app/radar/watches"; if (n.ref_type === "sweep" || n.ref_type === "radar_watch") return "/app/radar/watches";
if (n.ref_type === "radar") return "/app/radar/today"; if (n.ref_type === "radar") return "/app/radar";
// 任務/系統/無 ref任務中心 // 任務/系統/無 ref任務中心
return "/app/jobs"; return "/app/jobs";
} }

View File

@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { primaryNav } from "./nav";
import { normalizeAppPath, pageHelpKeys, resolvePageHelpId } from "./pageHelp"; import { normalizeAppPath, pageHelpKeys, resolvePageHelpId } from "./pageHelp";
describe("pageHelp", () => { describe("pageHelp", () => {
@ -6,6 +7,7 @@ describe("pageHelp", () => {
expect(resolvePageHelpId("/app/radar/watches")).toBe("radar_watches"); expect(resolvePageHelpId("/app/radar/watches")).toBe("radar_watches");
expect(resolvePageHelpId("/app/radar/today")).toBe("radar_today"); expect(resolvePageHelpId("/app/radar/today")).toBe("radar_today");
expect(resolvePageHelpId("/app/radar")).toBe("radar_today"); expect(resolvePageHelpId("/app/radar")).toBe("radar_today");
expect(primaryNav.find((item) => item.key === "radar")?.path).toBe("/app/radar");
expect(resolvePageHelpId("/app/crm/followups")).toBe("crm_followups"); expect(resolvePageHelpId("/app/crm/followups")).toBe("crm_followups");
expect(resolvePageHelpId("/app/crm/stats")).toBe("crm_stats"); expect(resolvePageHelpId("/app/crm/stats")).toBe("crm_stats");
expect(resolvePageHelpId("/app/crm?contact=x")).toBe("crm_board"); expect(resolvePageHelpId("/app/crm?contact=x")).toBe("crm_board");

View File

@ -69,7 +69,7 @@ const RULES: { prefix: string; id: PageHelpId }[] = [
/** 每個說明可連到的相關頁(可選) */ /** 每個說明可連到的相關頁(可選) */
export const PAGE_HELP_RELATED: Partial<Record<PageHelpId, PageHelpRelated[]>> = { export const PAGE_HELP_RELATED: Partial<Record<PageHelpId, PageHelpRelated[]>> = {
today: [ today: [
{ path: "/app/radar/today", labelKey: "nav.radar" }, { path: "/app/radar", labelKey: "nav.radar" },
{ path: "/app/scout", labelKey: "nav.scout" }, { path: "/app/scout", labelKey: "nav.scout" },
{ path: "/app/outbox", labelKey: "nav.outbox" }, { path: "/app/outbox", labelKey: "nav.outbox" },
], ],
@ -79,21 +79,21 @@ export const PAGE_HELP_RELATED: Partial<Record<PageHelpId, PageHelpRelated[]>> =
{ path: "/app/policy", labelKey: "nav.policy" }, { path: "/app/policy", labelKey: "nav.policy" },
], ],
radar_watches: [ radar_watches: [
{ path: "/app/radar/today", labelKey: "nav.radar" }, { path: "/app/radar", labelKey: "nav.radar" },
{ path: "/app/policy", labelKey: "nav.policy" }, { path: "/app/policy", labelKey: "nav.policy" },
], ],
crm_board: [ crm_board: [
{ path: "/app/crm/followups", labelKey: "crm.board.link.followups" }, { path: "/app/crm/followups", labelKey: "crm.board.link.followups" },
{ path: "/app/crm/stats", labelKey: "crm.board.link.stats" }, { path: "/app/crm/stats", labelKey: "crm.board.link.stats" },
{ path: "/app/radar/today", labelKey: "nav.radar" }, { path: "/app/radar", labelKey: "nav.radar" },
], ],
crm_followups: [ crm_followups: [
{ path: "/app/crm", labelKey: "crm.followups.link.board" }, { path: "/app/crm", labelKey: "crm.followups.link.board" },
{ path: "/app/radar/today", labelKey: "nav.radar" }, { path: "/app/radar", labelKey: "nav.radar" },
], ],
crm_stats: [{ path: "/app/crm", labelKey: "crm.followups.link.board" }], crm_stats: [{ path: "/app/crm", labelKey: "crm.followups.link.board" }],
scout: [ scout: [
{ path: "/app/radar/today", labelKey: "nav.radar" }, { path: "/app/radar", labelKey: "nav.radar" },
{ path: "/app/brands", labelKey: "nav.brands" }, { path: "/app/brands", labelKey: "nav.brands" },
], ],
brands: [ brands: [

View File

@ -1,8 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
checkThreadsTerm, checkThreadsTerm,
expandIncludeTerms,
isThreadsSearchable, isThreadsSearchable,
normalizeSearchTerm, normalizeSearchTerm,
searchableTermVariants,
selectThreadsSearchTerms, selectThreadsSearchTerms,
threadsPostIdentity, threadsPostIdentity,
} from "./threadsTerm"; } from "./threadsTerm";
@ -27,6 +29,19 @@ describe("threadsTerm", () => {
expect(checkThreadsTerm("")).toEqual({ ok: false, reason: "empty" }); expect(checkThreadsTerm("")).toEqual({ ok: false, reason: "empty" });
}); });
it("shortens long CJK sentences into searchable windows", () => {
expect(searchableTermVariants("保母 求推薦")).toEqual(["保母 求推薦"]);
const variants = searchableTermVariants("晚上睡覺容易口乾舌燥怎麼辦");
expect(variants.length).toBeGreaterThan(0);
expect(variants.every(isThreadsSearchable)).toBe(true);
expect(variants).toContain("晚上睡覺");
expect(variants).toContain("口乾舌燥");
expect(searchableTermVariants("台北 婚攝 推薦 價格")[0]).toBe("台北 婚攝");
expect(expandIncludeTerms(["求推薦", "晚上睡覺容易口乾舌燥怎麼辦"])).toEqual(
expect.arrayContaining(["求推薦", "晚上睡覺", "口乾舌燥"]),
);
});
it("selects inferred work queries when the original has too many tokens", () => { it("selects inferred work queries when the original has too many tokens", () => {
expect( expect(
selectThreadsSearchTerms("外包 工程師 後端", [ selectThreadsSearchTerms("外包 工程師 後端", [

View File

@ -74,6 +74,141 @@ export function isThreadsSearchable(term: string): boolean {
return checkThreadsTerm(term).ok; return checkThreadsTerm(term).ok;
} }
const SEARCH_FILLERS = [
"怎麼辦",
"求推薦",
"有沒有人",
"有人知道",
"請問一下",
"請問",
"想問",
"想找",
"有沒有",
"可以嗎",
"好不好",
];
const MAX_VARIANTS = 8;
const MAX_VARIANTS_PER_TERM = 3;
function splitSearchParts(raw: string): string[] {
const parts: string[] = [];
let buf = "";
const flush = () => {
const value = buf.trim();
if (value) parts.push(value);
buf = "";
};
for (const ch of raw) {
if (/[0-9A-Za-z]/.test(ch) || isCJK(ch)) buf += ch;
else flush();
}
flush();
return parts;
}
function stripSearchFillers(value: string): string {
let next = value;
for (const filler of SEARCH_FILLERS) next = next.split(filler).join("");
return next.replace(/[ ,,、。!?!?:的了嗎呢啊喔唷]+$/g, "").trim();
}
/**
* 使 Threads domain.SearchableTermVariants
* token 24
*/
export function searchableTermVariants(raw: string, include = true): string[] {
const normalized = normalizeSearchTerm(raw);
if (!normalized) return [];
const seen = new Set<string>();
const out: string[] = [];
const add = (term: string) => {
const next = normalizeSearchTerm(term);
if (!next || seen.has(next.toLowerCase())) return;
if (include) {
if (!isThreadsSearchable(next)) return;
} else if ([...next].length < 2 || [...next].length > 60) {
return;
}
seen.add(next.toLowerCase());
out.push(next);
};
if (include && isThreadsSearchable(normalized)) {
add(normalized);
return out;
}
if (!include) {
add(normalized);
if (out.length) return out;
}
const tokens = normalized.split(/\s+/).filter(Boolean);
if (tokens.length > MAX_TOKENS) {
for (let i = 0; i + 1 < tokens.length && out.length < MAX_VARIANTS; i += 1) {
add(`${tokens[i]} ${tokens[i + 1]}`);
}
for (const tok of tokens) {
if (out.length >= MAX_VARIANTS) break;
for (const variant of searchableTermVariants(tok, include)) {
add(variant);
if (out.length >= MAX_VARIANTS) break;
}
}
return out;
}
const parts = splitSearchParts(normalized);
for (const part of parts.length ? parts : [normalized]) {
if (out.length >= MAX_VARIANTS) break;
if (include && isThreadsSearchable(part)) {
add(part);
continue;
}
const stripped = stripSearchFillers(part);
if (stripped && stripped !== part) add(stripped);
const runes = [...(stripped || part)];
if (runes.length === 5) {
add(runes.slice(0, 3).join(""));
add(runes.slice(3).join(""));
}
for (const width of [4, 3, 2]) {
if (runes.length < width) continue;
add(runes.slice(0, width).join(""));
if (runes.length > width) add(runes.slice(-width).join(""));
}
if (out.length >= 3) continue;
for (let width = 4; width >= 2; width -= 1) {
if (runes.length < width) continue;
for (let start = 0; start + width <= runes.length && out.length < MAX_VARIANTS; start += 1) {
add(runes.slice(start, start + width).join(""));
}
}
}
return out;
}
/** 訂閱關鍵字:已合規保留,長句各收成最多 3 則可搜短詞。 */
export function expandIncludeTerms(terms: string[], maxTotal = 20): string[] {
const out: string[] = [];
const seen = new Set<string>();
for (const raw of terms) {
const normalized = normalizeSearchTerm(raw);
if (!normalized) continue;
const variants = isThreadsSearchable(normalized)
? [normalized]
: searchableTermVariants(normalized).slice(0, MAX_VARIANTS_PER_TERM);
for (const term of variants) {
const key = term.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(term);
if (out.length >= maxTotal) return out;
}
}
return out;
}
/** /**
* fan-out * fan-out
* 使 * 使

View File

@ -197,7 +197,7 @@ export function CrmBoardPage() {
<> <>
<PageHeader title={t("crm.board.title")} /> <PageHeader title={t("crm.board.title")} />
<div className="hb-radar-actions hb-radar-actions--toolbar"> <div className="hb-radar-actions hb-radar-actions--toolbar">
<Link className="hb-btn hb-btn--secondary" to="/app/radar/today"> <Link className="hb-btn hb-btn--secondary" to="/app/radar">
{t("crm.board.link.today")} {t("crm.board.link.today")}
</Link> </Link>
<Link className="hb-btn hb-btn--ghost" to="/app/crm/followups"> <Link className="hb-btn hb-btn--ghost" to="/app/crm/followups">
@ -262,7 +262,7 @@ export function CrmBoardPage() {
<EmptyState <EmptyState
title={t("crm.board.empty")} title={t("crm.board.empty")}
description={t("crm.board.emptyHint")} description={t("crm.board.emptyHint")}
action={<Link className="hb-btn hb-btn--secondary" to="/app/radar/today">{t("crm.board.link.today")}</Link>} action={<Link className="hb-btn hb-btn--secondary" to="/app/radar">{t("crm.board.link.today")}</Link>}
/> />
) : ( ) : (
<div className={`crm-workspace${detail ? " crm-workspace--detail" : ""}`}> <div className={`crm-workspace${detail ? " crm-workspace--detail" : ""}`}>

View File

@ -3,7 +3,7 @@ import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "../data/live/http"; import { ApiError } from "../data/live/http";
import { KEYS } from "../data/mock/keys"; import { KEYS } from "../data/mock/keys";
import type { Opportunity } from "../domain/types"; import type { Opportunity, RadarWatch } from "../domain/types";
import { I18nProvider } from "../i18n/I18nContext"; import { I18nProvider } from "../i18n/I18nContext";
import { RadarOpportunitiesPage } from "./RadarOpportunitiesPage"; import { RadarOpportunitiesPage } from "./RadarOpportunitiesPage";
@ -12,7 +12,12 @@ const backend = vi.hoisted(() => ({
calls: [] as Array<Record<string, unknown>>, calls: [] as Array<Record<string, unknown>>,
accepted: [] as string[], accepted: [] as string[],
reviews: [] as Array<{ id: string; patch: Record<string, unknown> }>, reviews: [] as Array<{ id: string; patch: Record<string, unknown> }>,
sweeps: [] as string[],
result: null as { list: Opportunity[]; total: number } | null, result: null as { list: Opportunity[]; total: number } | null,
byScope: null as null | ((filter: Record<string, unknown>) => { list: Opportunity[]; total: number }),
watches: [] as RadarWatch[],
jobResult: null as null | ((id: string) => { status: "succeeded" | "failed" | "running"; progress_summary: string; error?: string }),
lastSweptAt: Date.now() * 1e6,
})); }));
function opportunity(): Opportunity { function opportunity(): Opportunity {
@ -49,12 +54,52 @@ vi.mock("../data/DataContext", () => ({
async listBrands() { return [{ id: "b1", display_name: "澄光品牌", brief: "" }]; }, async listBrands() { return [{ id: "b1", display_name: "澄光品牌", brief: "" }]; },
async listProducts() { return [{ id: "p1", brand_id: "b1", label: "舒緩精華", product_context: "", match_tags: [], pain_points: [], provider_capability_terms: [], provider_exclude_terms: [], created_at: 1, updated_at: 1 }]; }, async listProducts() { return [{ id: "p1", brand_id: "b1", label: "舒緩精華", product_context: "", match_tags: [], pain_points: [], provider_capability_terms: [], provider_exclude_terms: [], created_at: 1, updated_at: 1 }]; },
}, },
jobs: {
async get(id: string) {
const configured = backend.jobResult?.(id);
return {
id,
template_type: "radar_sweep",
status: configured?.status ?? "succeeded",
progress_summary: configured?.progress_summary ?? "雷達巡檢完成 · 新建 1 · 再次命中 0 · 判定 1 · 截斷 0",
error: configured?.error,
progress_percent: 100,
created_at: 1,
updated_at: 1,
};
},
},
radar: { radar: {
async listOpportunities(filter: Record<string, unknown>) { async listOpportunities(filter: Record<string, unknown>) {
backend.calls.push(filter); backend.calls.push(filter);
if (backend.error) throw backend.error; if (backend.error) throw backend.error;
if (backend.byScope) return backend.byScope(filter);
return backend.result ?? { list: [opportunity()], total: 1 }; return backend.result ?? { list: [opportunity()], total: 1 };
}, },
async listWatches() {
return {
list: backend.watches,
total: backend.watches.length,
active_count: backend.watches.filter((w) => w.status === "active").length,
max_active: 5,
profile_exists: true,
};
},
async getToday() {
return {
stats: { total: 0, high: 0, mid: 0, low: 0 },
high: [], mid: [], low: [],
truncated_count: 0,
last_swept_at: backend.lastSweptAt,
};
},
async triggerWatchSweep(id: string) {
backend.sweeps.push(id);
return { job_id: `job-${id}` };
},
async listSweeps() {
return { list: [], total: 0 };
},
async acceptOpportunity(id: string) { async acceptOpportunity(id: string) {
backend.accepted.push(id); backend.accepted.push(id);
return { opportunity_id: id, contact_id: "contact-buyer", status: "accepted" }; return { opportunity_id: id, contact_id: "contact-buyer", status: "accepted" };
@ -69,9 +114,9 @@ vi.mock("../data/DataContext", () => ({
})(), })(),
})); }));
function renderPage() { function renderPage(path = "/app/radar/opportunities?brand_id=b1&product_id=p1&sort=posted") {
return render( return render(
<MemoryRouter initialEntries={["/app/radar/opportunities?brand_id=b1&product_id=p1&sort=posted"]}> <MemoryRouter initialEntries={[path]}>
<I18nProvider><RadarOpportunitiesPage /></I18nProvider> <I18nProvider><RadarOpportunitiesPage /></I18nProvider>
</MemoryRouter>, </MemoryRouter>,
); );
@ -83,10 +128,52 @@ beforeEach(() => {
backend.calls = []; backend.calls = [];
backend.accepted = []; backend.accepted = [];
backend.reviews = []; backend.reviews = [];
backend.sweeps = [];
backend.result = null; backend.result = null;
backend.byScope = null;
backend.jobResult = null;
backend.lastSweptAt = Date.now() * 1e6;
backend.watches = [{
id: "w1",
terms: ["敏感肌"],
exclude_terms: [],
regions: [],
status: "active",
last_swept_at: backend.lastSweptAt,
created_at: 1,
updated_at: 1,
}];
}); });
describe("RadarOpportunitiesPage", () => { describe("RadarOpportunitiesPage", () => {
it("shows scheduled patrol, last run, and run-now on the primary workplace", async () => {
renderPage("/app/radar");
expect(await screen.findByTestId("radar-patrol-desk")).toBeTruthy();
expect(screen.getByText("每日定時巡邏:開著")).toBeTruthy();
expect(screen.getByText(/上次巡邏:/)).toBeTruthy();
expect(screen.getByRole("button", { name: "立即巡邏" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "立即巡邏" }));
await waitFor(() => expect(backend.sweeps).toEqual(["w1"]));
});
it("runs every active watch and reports a real patrol failure", async () => {
backend.watches.push({
...backend.watches[0],
id: "w2",
terms: ["求推薦"],
});
backend.jobResult = (id) => id === "job-w2"
? { status: "failed", progress_summary: "今天沒巡到Chrome 登入已過期。" }
: { status: "succeeded", progress_summary: "雷達巡檢完成 · 新建 1" };
renderPage("/app/radar");
fireEvent.click(await screen.findByRole("button", { name: "立即巡邏" }));
await waitFor(() => expect(backend.sweeps).toEqual(["w1", "w2"]));
expect(await screen.findByRole("alert")).toHaveTextContent("今天沒巡到Chrome 登入已過期。");
expect(screen.queryByText("這一輪巡邏跑完了。找到的痛點會留在下面。")).toBeNull();
});
it("restores URL filters and removes product when brand is cleared", async () => { it("restores URL filters and removes product when brand is cleared", async () => {
renderPage(); renderPage();
expect(await screen.findByText("正在找適合敏感肌的日常修護產品")).toBeTruthy(); expect(await screen.findByText("正在找適合敏感肌的日常修護產品")).toBeTruthy();
@ -109,37 +196,62 @@ describe("RadarOpportunitiesPage", () => {
await screen.findByText("正在找適合敏感肌的日常修護產品"); await screen.findByText("正在找適合敏感肌的日常修護產品");
fireEvent.change(screen.getByRole("combobox", { name: "先看哪些" }), { target: { value: "newest" } }); fireEvent.change(screen.getByRole("combobox", { name: "先看哪些" }), { target: { value: "newest" } });
fireEvent.change(screen.getByRole("combobox", { name: "看哪段時間" }), { target: { value: "7d" } }); fireEvent.change(screen.getByRole("combobox", { name: "看哪段時間" }), { target: { value: "7d" } });
fireEvent.click(screen.getByRole("button", { name: "已處理" })); fireEvent.click(screen.getByRole("button", { name: "已看過" }));
await waitFor(() => expect(backend.calls.at(-1)).toMatchObject({ sort: "newest", time_scope: "7d", review_state: "completed" })); await waitFor(() => expect(backend.calls.at(-1)).toMatchObject({ sort: "newest", time_scope: "7d", review_state: "completed" }));
}); });
it("explains a filtered empty result instead of rendering a blank page", async () => { it("explains a filtered empty result instead of rendering a blank page", async () => {
backend.result = { list: [], total: 0 }; backend.result = { list: [], total: 0 };
renderPage(); renderPage();
expect(await screen.findByText("目前沒有待決定的商機")).toBeTruthy(); expect(await screen.findByText("這個篩選下沒有結果")).toBeTruthy();
expect(screen.getByText("清除篩選或改看其他時間範圍。")).toBeTruthy(); expect(screen.getAllByText(/近 7 天/).length).toBeGreaterThan(0);
}); });
it("把最重要的三個決策直接放在卡片,加入名單後提供下一步", async () => { it("widens today to 7d when patrol hits are older posts", async () => {
const older = opportunity();
older.id = "older-hit";
older.text = "舊文但是這輪才判定";
backend.result = { list: [], total: 0 };
backend.byScope = (filter: Record<string, unknown>) => {
if (filter.time_scope === "7d" && filter.review_state === "pending") {
return { list: [older], total: 1 };
}
return { list: [], total: 0 };
};
renderPage("/app/radar");
expect(await screen.findByText("舊文但是這輪才判定")).toBeTruthy();
expect(await screen.findByText(/已改看近 7 天/)).toBeTruthy();
});
it("explains an unfiltered empty workplace in patrol language", async () => {
backend.result = { list: [], total: 0 };
renderPage("/app/radar");
expect(await screen.findByTestId("radar-patrol-desk")).toBeTruthy();
expect(await screen.findByText("這輪有巡,但沒找到符合的痛點")).toBeTruthy();
expect(screen.getAllByText(/近 7 天/).length).toBeGreaterThan(0);
expect(screen.getAllByText(/全部/).length).toBeGreaterThan(0);
});
it("default actions are keep or discard; joining 名單 is optional", async () => {
renderPage(); renderPage();
await screen.findByText("正在找適合敏感肌的日常修護產品"); await screen.findByText("正在找適合敏感肌的日常修護產品");
expect(screen.getByRole("button", { name: "加入名單並追蹤" })).toBeTruthy(); expect(screen.getByRole("button", { name: "留下" })).toBeTruthy();
expect(screen.getByRole("button", { name: "只標示已處理" })).toBeTruthy(); expect(screen.getByRole("button", { name: "丟掉" })).toBeTruthy();
expect(screen.getByRole("button", { name: "不適合" })).toBeTruthy(); expect(screen.getByRole("button", { name: "加入名單(可選)" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "加入名單並追蹤" })); fireEvent.click(screen.getByRole("button", { name: "加入名單(可選)" }));
await screen.findByText("已加入名單並移到已處理。接下來可到名單安排追蹤、備註或回報成交。"); await screen.findByText("已加入名單。這步是可選的,之後要追蹤再去名單即可。");
expect(backend.accepted).toEqual(["opp-archive"]); expect(backend.accepted).toEqual(["opp-archive"]);
expect(screen.getByRole("link", { name: "現在前往名單" }).getAttribute("href")).toBe("/app/crm?contact=contact-buyer"); expect(screen.getByRole("link", { name: "前往名單" }).getAttribute("href")).toBe("/app/crm?contact=contact-buyer");
}); });
it("只標示已處理不會誤建名單", async () => { it("留下不會誤建名單", async () => {
renderPage(); renderPage();
await screen.findByText("正在找適合敏感肌的日常修護產品"); await screen.findByText("正在找適合敏感肌的日常修護產品");
fireEvent.click(screen.getByRole("button", { name: "只標示已處理" })); fireEvent.click(screen.getByRole("button", { name: "留下" }));
await screen.findByText("已標示為已處理;沒有建立聯絡人名單。"); await screen.findByText("已留下。沒有建立名單。");
expect(backend.accepted).toEqual([]); expect(backend.accepted).toEqual([]);
expect(backend.reviews).toEqual([{ id: "opp-archive", patch: { state: "completed" } }]); expect(backend.reviews).toEqual([{ id: "opp-archive", patch: { state: "completed" } }]);
}); });

View File

@ -1,12 +1,14 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState, type ReactNode } from "react";
import { Link, useSearchParams } from "react-router-dom"; import { Link, useSearchParams } from "react-router-dom";
import { PageHeader } from "../components/layout/PageHeader"; import { PageHeader } from "../components/layout/PageHeader";
import { OpportunityInboxCard } from "../components/radar/OpportunityInboxCard"; import { OpportunityInboxCard } from "../components/radar/OpportunityInboxCard";
import { OpportunityDetailDrawer } from "../components/radar/OpportunityDetailDrawer"; import { OpportunityDetailDrawer } from "../components/radar/OpportunityDetailDrawer";
import { SweepFunnelSummary } from "../components/radar/SweepFunnelSummary";
import { Button, EmptyState, Select } from "../components/ui"; import { Button, EmptyState, Select } from "../components/ui";
import { useRepos } from "../data/DataContext"; import { useRepos } from "../data/DataContext";
import type { Brand, BrandProduct, Opportunity, OpportunityRemovalReason, OpportunityReviewState, OpportunityTimeScope } from "../domain/types"; import type { Brand, BrandProduct, JobStatus, Opportunity, OpportunityRemovalReason, OpportunityReviewState, OpportunityTimeScope, RadarSweep, RadarToday, RadarWatch } from "../domain/types";
import { useFormatApiError } from "../lib/apiErrors"; import { useFormatApiError } from "../lib/apiErrors";
import { formatLocalDateTime } from "../lib/time";
import "../styles/radar.css"; import "../styles/radar.css";
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
@ -38,6 +40,12 @@ export function RadarOpportunitiesPage() {
const [products, setProducts] = useState<BrandProduct[]>([]); const [products, setProducts] = useState<BrandProduct[]>([]);
const [selected, setSelected] = useState<Opportunity | null>(null); const [selected, setSelected] = useState<Opportunity | null>(null);
const [busyId, setBusyId] = useState<string | null>(null); const [busyId, setBusyId] = useState<string | null>(null);
const [sweeping, setSweeping] = useState(false);
const [lastSweep, setLastSweep] = useState<RadarSweep | null>(null);
const [watches, setWatches] = useState<RadarWatch[]>([]);
const [activeCount, setActiveCount] = useState(0);
const [watchTotal, setWatchTotal] = useState(0);
const [todayMeta, setTodayMeta] = useState<RadarToday | null>(null);
const [advancedOpen, setAdvancedOpen] = useState(() => Boolean(params.get("band") || params.get("match_state") || params.get("brand_id") || params.get("product_id"))); const [advancedOpen, setAdvancedOpen] = useState(() => Boolean(params.get("band") || params.get("match_state") || params.get("brand_id") || params.get("product_id")));
const scout = (repos as unknown as { scout?: { listBrands: () => Promise<Brand[]>; listProducts: (id: string) => Promise<BrandProduct[]> } }).scout; const scout = (repos as unknown as { scout?: { listBrands: () => Promise<Brand[]>; listProducts: (id: string) => Promise<BrandProduct[]> } }).scout;
@ -53,20 +61,70 @@ export function RadarOpportunitiesPage() {
void scout.listProducts(brandId).then(setProducts).catch(() => setProducts([])); void scout.listProducts(brandId).then(setProducts).catch(() => setProducts([]));
}, [scout, brandId]); }, [scout, brandId]);
const loadPatrol = useCallback(async () => {
const [watchRes, today] = await Promise.all([
repos.radar.listWatches(1, 50).catch(() => null),
repos.radar.getToday().catch(() => null),
]);
if (watchRes) {
setWatches(watchRes.list);
setActiveCount(watchRes.active_count);
setWatchTotal(watchRes.total);
}
setTodayMeta(today);
if (typeof repos.radar.listSweeps === "function") {
const sweeps = await repos.radar.listSweeps(1, 1).catch(() => null);
setLastSweep(sweeps?.list[0] ?? null);
}
}, [repos.radar]);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
try { const query = (scope: OpportunityTimeScope, review: OpportunityReviewState = reviewState) =>
const result = await repos.radar.listOpportunities({ repos.radar.listOpportunities({
page, page: scope === timeScope ? page : 1,
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
band: band || undefined, band: band || undefined,
match_state: state || undefined, match_state: state || undefined,
brand_id: brandId || undefined, brand_id: brandId || undefined,
product_id: productId || undefined, product_id: productId || undefined,
review_state: reviewState, review_state: review,
time_scope: timeScope, time_scope: scope,
sort, sort,
}); });
try {
let result = await query(timeScope);
const canWiden = reviewState === "pending" && timeScope === "today" && !band && !state && !brandId && !productId;
if (canWiden && result.total === 0) {
const week = await query("7d");
if (week.total > 0) {
setTimeScope("7d");
setPage(1);
const next = new URLSearchParams(typeof window === "undefined" ? "" : window.location.search);
next.set("time_scope", "7d");
next.delete("page");
setParams(next, { replace: true });
result = week;
setNotice({ text: `巡邏結果不是都在「今天發的文」。已改看近 7 天(${week.total} 筆)。任務上的判定/新建數字包含同一篇再命中,不一定全是新卡片。` });
} else {
const all = await query("all");
if (all.total > 0) {
setTimeScope("all");
setPage(1);
const next = new URLSearchParams(typeof window === "undefined" ? "" : window.location.search);
next.set("time_scope", "all");
next.delete("page");
setParams(next, { replace: true });
result = all;
setNotice({ text: `近 7 天沒有待處理結果,已改看全部(${all.total} 筆)。` });
} else {
const seen = await query("7d", "completed");
if (seen.total > 0) {
setNotice({ text: `這輪判定到的 ${seen.total} 筆已在「已看過」,所以「新找到」是空的。任務數字含再次命中的舊文。` });
}
}
}
}
setList(result.list); setList(result.list);
setTotal(result.total); setTotal(result.total);
setError(""); setError("");
@ -75,9 +133,10 @@ export function RadarOpportunitiesPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [repos.radar, page, band, state, brandId, productId, sort, reviewState, timeScope, formatError]); }, [repos.radar, page, band, state, brandId, productId, sort, reviewState, timeScope, formatError, setParams]);
useEffect(() => { void load(); }, [load]); useEffect(() => { void load(); }, [load]);
useEffect(() => { void loadPatrol(); }, [loadPatrol]);
async function updateReviewState( async function updateReviewState(
opportunity: Opportunity, opportunity: Opportunity,
@ -108,7 +167,7 @@ export function RadarOpportunitiesPage() {
await load(); await load();
setError(""); setError("");
setNotice({ setNotice({
text: "已加入名單並移到已處理。接下來可到名單安排追蹤、備註或回報成交。", text: "已加入名單。這步是可選的,之後要追蹤再去名單即可。",
contactId: result.contact_id, contactId: result.contact_id,
}); });
} catch (e) { } catch (e) {
@ -118,6 +177,84 @@ export function RadarOpportunitiesPage() {
} }
} }
async function waitForJob(jobId: string): Promise<{ status: JobStatus; progress_summary: string; error: string; timedOut: boolean }> {
const deadline = Date.now() + 120_000;
let last: { status: JobStatus; progress_summary: string; error: string } = {
status: "queued",
progress_summary: "立即巡邏已排程 · 等待 worker",
error: "",
};
while (Date.now() < deadline) {
const job = await repos.jobs.get(jobId);
if (job) {
last = { status: job.status, progress_summary: job.progress_summary, error: job.error || "" };
if (job.status === "succeeded" || job.status === "failed" || job.status === "cancelled") {
return { ...last, timedOut: false };
}
}
await new Promise((resolve) => window.setTimeout(resolve, 1500));
}
return { ...last, timedOut: true };
}
async function runNow() {
const active = watches.filter((watch) => watch.status === "active");
if (!active.length) {
setError("沒有開著的每日巡邏。先設定要巡的產品與關鍵字,或恢復一組訂閱。");
return;
}
setSweeping(true);
setError("");
setNotice(null);
try {
const jobIds: string[] = [];
for (const watch of active) {
const res = await repos.radar.triggerWatchSweep(watch.id);
if (res.job_id) jobIds.push(res.job_id);
}
if (!jobIds.length) {
setError("沒有排到巡邏任務。");
return;
}
setNotice({ text: "立即巡邏進行中… 跑完才會把痛點列在下面。" });
// All active watches were queued together, so observe them together too.
// Waiting serially made the page look frozen for up to 120s per watch.
const finished = await Promise.all(jobIds.map((id) => waitForJob(id)));
const failed = finished.find((job) => job.status === "failed");
const cancelled = finished.find((job) => job.status === "cancelled");
const running = finished.filter((job) => job.timedOut || job.status === "pending" || job.status === "queued" || job.status === "running" || job.status === "cancel_requested");
const terminalError = failed
? failed.progress_summary || failed.error || "巡邏失敗。"
: cancelled
? "巡邏已取消,不會誤顯示為已完成。可再按一次立即巡邏。"
: "";
if (typeof repos.radar.listSweeps === "function") {
const sweeps = await repos.radar.listSweeps(1, 1).catch(() => null);
setLastSweep(sweeps?.list[0] ?? null);
}
await Promise.all([load(), loadPatrol()]);
const summary = finished.map((job) => job.progress_summary).filter(Boolean).join(" ");
// load() clears stale list errors on a successful refresh. Re-apply the
// job outcome afterwards so the actual patrol failure stays visible.
if (terminalError) {
setError(terminalError);
setNotice(null);
} else if (running.length > 0) {
setNotice({ text: `已排入 ${jobIds.length} 組巡邏,目前仍在後台執行。可先離開這頁,完成後結果會留在這裡。` });
} else {
setNotice({
text: summary.includes("新建")
? `${summary} 不是今天發的文也會留在下面。`
: "這一輪巡邏跑完了。找到的痛點會留在下面。",
});
}
} catch (e) {
setError(formatError(e));
} finally {
setSweeping(false);
}
}
function writeParams(changes: Record<string, string>) { function writeParams(changes: Record<string, string>) {
const next = new URLSearchParams(params); const next = new URLSearchParams(params);
for (const [name, value] of Object.entries(changes)) { for (const [name, value] of Object.entries(changes)) {
@ -137,60 +274,144 @@ export function RadarOpportunitiesPage() {
setAdvancedOpen(false); setAdvancedOpen(false);
setPage(1); setPage(1);
const next = new URLSearchParams(); const next = new URLSearchParams();
next.set("review_state", reviewState); if (reviewState !== "pending") next.set("review_state", reviewState);
next.set("time_scope", "today");
setParams(next, { replace: true }); setParams(next, { replace: true });
} }
const lastSweptAt = Math.max(
todayMeta?.last_swept_at ?? 0,
...watches.map((watch) => watch.last_swept_at ?? 0),
);
const scheduledOn = activeCount > 0;
const advancedFilterCount = [band, state, brandId, productId].filter(Boolean).length; const advancedFilterCount = [band, state, brandId, productId].filter(Boolean).length;
const filtered = Boolean(advancedFilterCount || sort !== "recommended" || timeScope !== "today"); const filtered = Boolean(advancedFilterCount || sort !== "recommended" || timeScope !== "today");
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE)); const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
function emptyCopy(): { title: string; description: string; action?: ReactNode } {
if (filtered) {
return {
title: reviewState === "pending" ? "這個篩選下沒有結果" : reviewState === "completed" ? "目前沒有已看過的結果" : "目前沒有已丟掉的結果",
description: "清除篩選或改看其他時間範圍。巡邏剛跑完的結果也可能在「近 7 天」或「全部」。",
action: <Button type="button" variant="ghost" onClick={resetFilters}></Button>,
};
}
if (reviewState === "completed") {
return { title: "還沒有已看過的結果", description: "切回「新找到」繼續看巡邏到的痛點。" };
}
if (reviewState === "removed") {
return { title: "還沒有丟掉的結果", description: "切回「新找到」繼續看巡邏到的痛點。" };
}
if (watchTotal === 0) {
return {
title: "還沒設定巡邏",
description: "先選產品與客人會搜的關鍵字。設好後可立即巡邏,每日定時巡邏也會接著跑。",
action: <Link className="hb-btn hb-btn--secondary" to="/app/radar/watches"></Link>,
};
}
if (!scheduledOn) {
return {
title: "每日定時巡邏關著",
description: "立即巡邏與每日定時都還在這個頁面。恢復至少一組訂閱後,兩個都能用;關掉其中一個不會藏掉另一個。",
action: <Link className="hb-btn hb-btn--secondary" to="/app/radar/watches"></Link>,
};
}
if (!lastSweptAt) {
return {
title: "還沒巡邏過",
description: "每日定時巡邏已開著,也可現在按「立即巡邏」。不是空白收件匣,只是第一輪還沒跑完。",
action: <Button type="button" onClick={() => void runNow()} disabled={sweeping}>{sweeping ? "巡邏中…" : "立即巡邏"}</Button>,
};
}
if (todayMeta?.empty_reason === "sweep_failed") {
const crawlerDead = /crawler session|Chrome crawler|Chrome 登入已過期/i.test(
`${todayMeta.empty_hint || ""} ${lastSweep?.failed_reason || ""}`,
);
return {
title: "上一輪巡邏沒跑完",
description: todayMeta.empty_hint || "巡邏失敗。可再按立即巡邏,或改看近 7 天/全部。",
action: (
<>
{crawlerDead ? <Link className="hb-btn hb-btn--secondary" to="/app/settings"> Chrome</Link> : null}
<Button type="button" onClick={() => void runNow()} disabled={sweeping}>{sweeping ? "巡邏中…" : "再巡一次"}</Button>
</>
),
};
}
if (lastSweep && lastSweep.hit_count === 0) {
return {
title: "搜尋沒撈到貼文",
description: "門檻前就空了:關鍵字太長、太產品名、或 Threads 查無結果。改成客人會打的 24 字痛點詞再巡。",
action: <Link className="hb-btn hb-btn--secondary" to="/app/radar/watches"></Link>,
};
}
return {
title: "這輪有巡,但沒找到符合的痛點",
description: lastSweep
? `搜尋命中 ${lastSweep.hit_count}、判定 ${lastSweep.judged_count}、新建 ${lastSweep.created_count}。不是今天發的文可改看近 7 天/全部。`
: "新文章或產品對得上的需求會出現在這裡。也可改看「近 7 天」或「全部」,或調整要巡的關鍵字。",
action: <Button type="button" variant="ghost" onClick={() => { setTimeScope("7d"); setPage(1); writeParams({ time_scope: "7d", page: "" }); }}> 7 </Button>,
};
}
const empty = emptyCopy();
return ( return (
<> <>
<PageHeader title="商機收件匣" /> <PageHeader title="商機" />
<section className="hb-radar-patrol" data-testid="radar-patrol-desk" aria-label="巡邏狀態">
<div className="hb-radar-patrol__status">
<p>
<strong>{scheduledOn ? "每日定時巡邏:開著" : "每日定時巡邏:關著"}</strong>
<span> 06:00 </span>
</p>
<p>
<strong>{lastSweptAt ? `上次巡邏:${formatLocalDateTime(lastSweptAt)}` : "還沒巡邏過"}</strong>
<span>{scheduledOn ? `啟用中 ${activeCount}` : watchTotal ? "訂閱都暫停了,立即巡邏也需要至少一組開著" : "還沒設定要巡的產品與關鍵字"}</span>
</p>
</div>
<div className="hb-radar-patrol__actions">
<Button type="button" onClick={() => void runNow()} disabled={sweeping || !scheduledOn}>
{sweeping ? "巡邏中…" : "立即巡邏"}
</Button>
<Link className="hb-btn hb-btn--ghost" to="/app/radar/watches"></Link>
<small>Chrome API provider </small>
</div>
</section>
{lastSweep ? <SweepFunnelSummary sweep={lastSweep} /> : null}
<section className="hb-radar-intro"> <section className="hb-radar-intro">
<div> <div>
<strong></strong> <strong></strong>
<p></p> <p></p>
</div> </div>
<nav className="hb-radar-intro__actions" aria-label="商機結果導覽">
<Link className="hb-btn hb-btn--ghost" to="/app/radar/watches"></Link>
</nav>
</section> </section>
<ol className="hb-inbox-decision-guide" aria-label="商機收件匣操作方式"> <section className="hb-radar-filter-panel" aria-label="商機結果">
<li><span>1</span><div><strong></strong><small></small></div></li>
<li><span>2</span><div><strong></strong><small></small></div></li>
<li><span>3</span><div><strong></strong><small></small></div></li>
</ol>
<section className="hb-radar-filter-panel" aria-label="篩選商機收件匣">
<div className="hb-radar-filter-panel__head"> <div className="hb-radar-filter-panel__head">
<div className="hb-radar-inbox-tabs" role="tablist" aria-label="商機工作狀態"> <div className="hb-radar-inbox-tabs" role="tablist" aria-label="結果狀態">
{(["pending", "completed", "removed"] as OpportunityReviewState[]).map((value) => ( {(["pending", "completed", "removed"] as OpportunityReviewState[]).map((value) => (
<Button key={value} type="button" variant={reviewState === value ? "primary" : "ghost"} aria-pressed={reviewState === value} onClick={() => { <Button key={value} type="button" variant={reviewState === value ? "primary" : "ghost"} aria-pressed={reviewState === value} onClick={() => {
setReviewState(value); setPage(1); writeParams({ review_state: value, page: "" }); setReviewState(value); setPage(1); writeParams({ review_state: value === "pending" ? "" : value, page: "" });
}}> }}>
{value === "pending" ? "待決定" : value === "completed" ? "已處理" : "已移除"} {value === "pending" ? "新找到" : value === "completed" ? "已看過" : "已丟掉"}
</Button> </Button>
))} ))}
<span> {total} </span> <span> {total} </span>
</div> </div>
{filtered ? <Button type="button" variant="ghost" onClick={resetFilters}></Button> : <span></span>} {filtered ? <Button type="button" variant="ghost" onClick={resetFilters}></Button> : <span></span>}
</div> </div>
<div className="hb-radar-inbox-essential-filters"> <div className="hb-radar-inbox-essential-filters">
<Select name="all-time-scope" label="看哪段時間" value={timeScope} onChange={(e) => { <Select name="all-time-scope" label="看哪段時間" value={timeScope} onChange={(e) => {
const value = e.target.value as OpportunityTimeScope; const value = e.target.value as OpportunityTimeScope;
setTimeScope(value); setPage(1); writeParams({ time_scope: value, page: "" }); setTimeScope(value); setPage(1); writeParams({ time_scope: value === "today" ? "" : value, page: "" });
}}> }}>
<option value="today"></option><option value="7d"> 7 </option><option value="all"></option> <option value="today"></option><option value="7d"> 7 </option><option value="all"></option>
</Select> </Select>
<Select name="all-sort" label="先看哪些" value={sort} onChange={(e) => { <Select name="all-sort" label="先看哪些" value={sort} onChange={(e) => {
setSort(e.target.value); setPage(1); writeParams({ sort: e.target.value === "recommended" ? "" : e.target.value, page: "" }); setSort(e.target.value); setPage(1); writeParams({ sort: e.target.value === "recommended" ? "" : e.target.value, page: "" });
}}> }}>
<option value="recommended"></option><option value="newest"></option><option value="oldest"></option><option value="product_fit"></option><option value="demand_intent"></option> <option value="recommended"></option><option value="newest"></option><option value="oldest"></option><option value="product_fit"></option><option value="demand_intent"></option>
</Select> </Select>
<Button type="button" variant="ghost" aria-expanded={advancedOpen} onClick={() => setAdvancedOpen((value) => !value)}> <Button type="button" variant="ghost" aria-expanded={advancedOpen} onClick={() => setAdvancedOpen((value) => !value)}>
{advancedOpen ? "收起更多篩選" : `更多篩選${advancedFilterCount ? `${advancedFilterCount}` : ""}`} {advancedOpen ? "收起更多篩選" : `更多篩選${advancedFilterCount ? `${advancedFilterCount}` : ""}`}
@ -230,14 +451,10 @@ export function RadarOpportunitiesPage() {
</section> </section>
{error ? <p className="hb-banner-error" role="alert">{error}</p> : null} {error ? <p className="hb-banner-error" role="alert">{error}</p> : null}
{notice ? <div className="hb-banner-ok" role="status"><span>{notice.text}</span>{notice.contactId ? <Link to={`/app/crm?contact=${encodeURIComponent(notice.contactId)}`}></Link> : null}</div> : null} {notice ? <div className="hb-banner-ok" role="status"><span>{notice.text}</span>{notice.contactId ? <Link to={`/app/crm?contact=${encodeURIComponent(notice.contactId)}`}></Link> : null}</div> : null}
{loading ? <p className="hb-radar-section__hint" role="status"></p> : null} {loading ? <p className="hb-radar-section__hint" role="status"></p> : null}
{!loading && !error && !list.length ? ( {!loading && !error && !list.length ? (
<EmptyState <EmptyState title={empty.title} description={empty.description} action={empty.action} />
title={reviewState === "pending" ? "目前沒有待決定的商機" : reviewState === "completed" ? "目前沒有已處理的商機" : "目前沒有已移除的商機"}
description={filtered ? "清除篩選或改看其他時間範圍。" : reviewState === "pending" ? "等待下一輪巡邏,或先建立產品巡邏。" : "切換到「待決定」繼續處理商機。"}
action={filtered ? <Button type="button" variant="ghost" onClick={resetFilters}></Button> : reviewState === "pending" ? <Link className="hb-btn hb-btn--secondary" to="/app/radar/watches"></Link> : undefined}
/>
) : null} ) : null}
{!error ? ( {!error ? (
@ -248,9 +465,9 @@ export function RadarOpportunitiesPage() {
busy={busyId === o.id} busy={busyId === o.id}
onOpen={setSelected} onOpen={setSelected}
onAccept={(item) => void acceptOpportunity(item)} onAccept={(item) => void acceptOpportunity(item)}
onComplete={(item) => void updateReviewState(item, { state: "completed" }, "已標示為已處理;沒有建立聯絡人名單。")} onComplete={(item) => void updateReviewState(item, { state: "completed" }, "已留下。沒有建立名單。")}
onRemove={(item, input) => void updateReviewState(item, { state: "removed", removal_reason: input.reason, removal_note: input.note }, "已標示為不適合,可從「已移除」還原。")} onRemove={(item, input) => void updateReviewState(item, { state: "removed", removal_reason: input.reason, removal_note: input.note }, "已丟掉。可從「已丟掉」還原。")}
onRestore={(item) => void updateReviewState(item, { state: item.previous_review_state === "completed" ? "completed" : "pending" }, "已還原商機。")} onRestore={(item) => void updateReviewState(item, { state: item.previous_review_state === "completed" ? "completed" : "pending" }, "已還原。")}
/>)} />)}
{total > PAGE_SIZE ? ( {total > PAGE_SIZE ? (
<div className="hb-radar-pager"> <div className="hb-radar-pager">
@ -266,7 +483,7 @@ export function RadarOpportunitiesPage() {
busy={busyId === selected.id} busy={busyId === selected.id}
onClose={() => setSelected(null)} onClose={() => setSelected(null)}
onAccept={(item) => void acceptOpportunity(item)} onAccept={(item) => void acceptOpportunity(item)}
onComplete={(item) => void updateReviewState(item, { state: "completed" }, "已標示為已處理;沒有建立聯絡人名單。")} onComplete={(item) => void updateReviewState(item, { state: "completed" }, "已留下。沒有建立名單。")}
/> : null} /> : null}
</> </>
); );

View File

@ -175,29 +175,29 @@ describe("RadarWatchesPage", () => {
renderPage(); renderPage();
await screen.findByText(t("radar.watches.empty")); await screen.findByText(t("radar.watches.empty"));
await createWatch("推薦室內設計\n找設計師"); await createWatch("室內設計\n找設計師");
await screen.findByText(t("radar.watches.created")); await screen.findByText(t("radar.watches.created"));
expect(within(row("推薦室內設計")).getByText(t("radar.watches.status.active"))).toBeTruthy(); expect(within(row("室內設計")).getByText(t("radar.watches.status.active"))).toBeTruthy();
expect(screen.getByText(t("radar.watches.quota", { used: 1, max: 5 }))).toBeTruthy(); expect(screen.getByText(t("radar.watches.quota", { used: 1, max: 5 }))).toBeTruthy();
fireEvent.click(within(row("推薦室內設計")).getByRole("button", { name: t("radar.watches.pause") })); fireEvent.click(within(row("室內設計")).getByRole("button", { name: t("radar.watches.pause") }));
await screen.findByText(t("radar.watches.paused")); await screen.findByText(t("radar.watches.paused"));
expect(within(row("推薦室內設計")).getByText(t("radar.watches.status.paused"))).toBeTruthy(); expect(within(row("室內設計")).getByText(t("radar.watches.status.paused"))).toBeTruthy();
expect(screen.getByText(t("radar.watches.quota", { used: 0, max: 5 }))).toBeTruthy(); expect(screen.getByText(t("radar.watches.quota", { used: 0, max: 5 }))).toBeTruthy();
fireEvent.click(within(row("推薦室內設計")).getByRole("button", { name: t("radar.watches.resume") })); fireEvent.click(within(row("室內設計")).getByRole("button", { name: t("radar.watches.resume") }));
await screen.findByText(t("radar.watches.resumed")); await screen.findByText(t("radar.watches.resumed"));
expect(within(row("推薦室內設計")).getByText(t("radar.watches.status.active"))).toBeTruthy(); expect(within(row("室內設計")).getByText(t("radar.watches.status.active"))).toBeTruthy();
fireEvent.click(within(row("推薦室內設計")).getByRole("button", { name: t("radar.watches.archive") })); fireEvent.click(within(row("室內設計")).getByRole("button", { name: t("radar.watches.archive") }));
await screen.findByText(t("radar.watches.archived")); await screen.findByText(t("radar.watches.archived"));
const archived = row("推薦室內設計"); const archived = row("室內設計");
expect(within(archived).getByText(t("radar.watches.status.archived"))).toBeTruthy(); expect(within(archived).getByText(t("radar.watches.status.archived"))).toBeTruthy();
// 封存後只能刪除設定;既有歷史資料不會被這個動作連帶刪除。 // 封存後只能刪除設定;既有歷史資料不會被這個動作連帶刪除。
const deleteButton = within(archived).getByRole("button", { name: t("radar.watches.deleteArchived") }); const deleteButton = within(archived).getByRole("button", { name: t("radar.watches.deleteArchived") });
fireEvent.click(deleteButton); fireEvent.click(deleteButton);
await screen.findByText(t("radar.watches.deletedArchived")); await screen.findByText(t("radar.watches.deletedArchived"));
expect(screen.queryByText("推薦室內設計")).toBeNull(); expect(screen.queryByText("室內設計")).toBeNull();
}); });
it("配額滿時照後端訊息說上限與升級,表單留著讓人改", async () => { it("配額滿時照後端訊息說上限與升級,表單留著讓人改", async () => {
@ -205,7 +205,7 @@ describe("RadarWatchesPage", () => {
renderPage(); renderPage();
await screen.findByText(t("radar.watches.empty")); await screen.findByText(t("radar.watches.empty"));
await createWatch("推薦室內設計"); await createWatch("室內設計");
await screen.findByText(t("radar.watches.created")); await screen.findByText(t("radar.watches.created"));
expect(screen.getByText(t("radar.watches.quotaFull"), { exact: false })).toBeTruthy(); expect(screen.getByText(t("radar.watches.quotaFull"), { exact: false })).toBeTruthy();
@ -218,7 +218,7 @@ describe("RadarWatchesPage", () => {
it("關鍵字建議可逐條採用include 進關鍵字、exclude 進排除詞", async () => { it("關鍵字建議可逐條採用include 進關鍵字、exclude 進排除詞", async () => {
backend.suggestions = [ backend.suggestions = [
{ term: "推薦室內設計", reason: "客人找設計師時最常這樣問", usage: "include" }, { term: "室內設計", reason: "客人找設計師時最常這樣問", usage: "include" },
{ term: "徵才", reason: "這類是招募文,不是客人", usage: "exclude" }, { term: "徵才", reason: "這類是招募文,不是客人", usage: "exclude" },
]; ];
renderPage(); renderPage();
@ -233,7 +233,7 @@ describe("RadarWatchesPage", () => {
expect( expect(
(screen.getByLabelText(t("radar.watches.terms"), { exact: false }) as HTMLTextAreaElement).value, (screen.getByLabelText(t("radar.watches.terms"), { exact: false }) as HTMLTextAreaElement).value,
).toBe("推薦室內設計"); ).toBe("室內設計");
expect( expect(
(screen.getByLabelText(t("radar.watches.excludeTerms"), { exact: false }) as HTMLTextAreaElement) (screen.getByLabelText(t("radar.watches.excludeTerms"), { exact: false }) as HTMLTextAreaElement)
.value, .value,
@ -259,4 +259,13 @@ describe("RadarWatchesPage", () => {
expect(banner.textContent).toContain("service profile required before suggesting watch terms"); expect(banner.textContent).toContain("service profile required before suggesting watch terms");
expect(screen.queryByText(t("radar.suggest.none"))).toBeNull(); expect(screen.queryByText(t("radar.suggest.none"))).toBeNull();
}); });
it("長句關鍵字儲存時收成可搜短詞", async () => {
renderPage();
await screen.findByText(t("radar.watches.empty"));
await createWatch("晚上睡覺容易口乾舌燥怎麼辦");
await screen.findByText(t("radar.watches.created"));
expect(screen.getByText("晚上睡覺")).toBeTruthy();
expect(screen.getByText("口乾舌燥")).toBeTruthy();
});
}); });

View File

@ -9,7 +9,8 @@ import type { Brand, BrandProduct, RadarSweep, RadarWatch, RadarWatchStatus, Wat
import type { DemandMap } from "../domain/types"; import type { DemandMap } from "../domain/types";
import { useI18n } from "../i18n/I18nContext"; import { useI18n } from "../i18n/I18nContext";
import { useFormatApiError } from "../lib/apiErrors"; import { useFormatApiError } from "../lib/apiErrors";
import { isThreadsSearchable } from "../lib/threadsTerm"; import { expandIncludeTerms, isThreadsSearchable } from "../lib/threadsTerm";
import { buildQueryPlan } from "../components/radar/QueryPlanPreview";
import { formatLocalDateTime } from "../lib/time"; import { formatLocalDateTime } from "../lib/time";
import { ProductWatchForm } from "../components/radar/ProductWatchForm"; import { ProductWatchForm } from "../components/radar/ProductWatchForm";
import { DemandMapEditor, type DemandMapPatch } from "../components/radar/DemandMapEditor"; import { DemandMapEditor, type DemandMapPatch } from "../components/radar/DemandMapEditor";
@ -96,6 +97,16 @@ export function RadarWatchesPage() {
return () => { alive = false; }; return () => { alive = false; };
}, [draft.productId, productContextAvailable, repos.radar, formatError]); }, [draft.productId, productContextAvailable, repos.radar, formatError]);
useEffect(() => {
if (!formOpen || draft.id || !demandMap || demandMap.state !== "ready") return;
setDraft((d) => {
if (splitTerms(d.terms).length > 0) return d;
const seeded = expandIncludeTerms(buildQueryPlan(demandMap).map((group) => group.terms.join(" ")));
if (!seeded.length) return d;
return { ...d, terms: seeded.join("\n") };
});
}, [demandMap, formOpen, draft.id]);
useEffect(() => { useEffect(() => {
if (!scout) return; if (!scout) return;
void scout.listBrands().then(setBrands).catch(() => setBrands([])); void scout.listBrands().then(setBrands).catch(() => setBrands([]));
@ -167,9 +178,14 @@ export function RadarWatchesPage() {
function adopt(s: WatchTermSuggestion) { function adopt(s: WatchTermSuggestion) {
setDraft((d) => { setDraft((d) => {
const key = s.usage === "exclude" ? "excludeTerms" : "terms"; const key = s.usage === "exclude" ? "excludeTerms" : "terms";
const incoming = s.usage === "exclude" ? [s.term] : expandIncludeTerms([s.term]);
const current = splitTerms(d[key]); const current = splitTerms(d[key]);
if (current.some((x) => x.toLowerCase() === s.term.toLowerCase())) return d; const next = [...current];
return { ...d, [key]: [...current, s.term].join("\n") }; for (const term of incoming) {
if (!next.some((x) => x.toLowerCase() === term.toLowerCase())) next.push(term);
}
if (next.length === current.length) return d;
return { ...d, [key]: next.join("\n") };
}); });
} }
@ -177,6 +193,19 @@ export function RadarWatchesPage() {
for (const s of list) adopt(s); for (const s of list) adopt(s);
} }
function adoptQueries(queries: string[]) {
setDraft((d) => {
const incoming = expandIncludeTerms(queries);
const current = splitTerms(d.terms);
const next = [...current];
for (const term of incoming) {
if (!next.some((x) => x.toLowerCase() === term.toLowerCase())) next.push(term);
}
if (next.length === current.length) return d;
return { ...d, terms: next.join("\n") };
});
}
/** 一次動作 → 重讀清單:狀態變化會連動配額與 profile 提示,局部改 state 容易對不起來。 */ /** 一次動作 → 重讀清單:狀態變化會連動配額與 profile 提示,局部改 state 容易對不起來。 */
async function run( async function run(
key: string, key: string,
@ -201,8 +230,12 @@ export function RadarWatchesPage() {
} }
async function save() { async function save() {
const terms = splitTerms(draft.terms); const terms = expandIncludeTerms(splitTerms(draft.terms));
const excludeTerms = splitTerms(draft.excludeTerms); const excludeTerms = splitTerms(draft.excludeTerms);
if (!terms.length) {
setError(t("radar.watches.threadsRequired"));
return;
}
if (!draft.id && productContextAvailable && (!draft.brandId || !draft.productId)) { if (!draft.id && productContextAvailable && (!draft.brandId || !draft.productId)) {
setError("請先選擇品牌與產品,產品型雷達才能啟用。" ); setError("請先選擇品牌與產品,產品型雷達才能啟用。" );
return; return;
@ -308,7 +341,7 @@ export function RadarWatchesPage() {
{justTriggeredFirstSweep ? ( {justTriggeredFirstSweep ? (
<> <>
{" "} {" "}
<Link to="/app/radar/today">{t("today.radar.open")}</Link> <Link to="/app/radar">{t("today.radar.open")}</Link>
</> </>
) : null} ) : null}
</p> </p>
@ -326,7 +359,7 @@ export function RadarWatchesPage() {
<strong>{t("radar.watches.scheduleTitle")}</strong> <strong>{t("radar.watches.scheduleTitle")}</strong>
<p>{t("radar.watches.scheduleHint")}</p> <p>{t("radar.watches.scheduleHint")}</p>
</div> </div>
<Link className="hb-btn hb-btn--ghost" to="/app/radar/opportunities?review_state=pending&time_scope=today"> <Link className="hb-btn hb-btn--ghost" to="/app/radar">
{t("radar.watches.openToday")} {t("radar.watches.openToday")}
</Link> </Link>
</div> </div>
@ -366,7 +399,7 @@ export function RadarWatchesPage() {
brandId={draft.brandId} brandId={draft.brandId}
productId={draft.productId} productId={draft.productId}
onBrandChange={(brandId) => setDraft((d) => ({ ...d, brandId, productId: "" }))} onBrandChange={(brandId) => setDraft((d) => ({ ...d, brandId, productId: "" }))}
onProductChange={(productId) => setDraft((d) => ({ ...d, productId }))} onProductChange={(productId) => setDraft((d) => ({ ...d, productId, terms: d.id ? d.terms : "" }))}
/> />
) : null} ) : null}
{productContextAvailable && draft.productId ? (() => { {productContextAvailable && draft.productId ? (() => {
@ -449,9 +482,10 @@ export function RadarWatchesPage() {
<WatchSuggestPanel <WatchSuggestPanel
onAdopt={adopt} onAdopt={adopt}
onAdoptAll={adoptAll} onAdoptAll={adoptAll}
onAdoptQueries={adoptQueries}
adopted={[...splitTerms(draft.terms), ...splitTerms(draft.excludeTerms)]} adopted={[...splitTerms(draft.terms), ...splitTerms(draft.excludeTerms)]}
context={draft.brandId && draft.productId ? { brand_id: draft.brandId, product_id: draft.productId } : undefined} context={draft.brandId && draft.productId ? { brand_id: draft.brandId, product_id: draft.productId } : undefined}
demandMap={demandMap ?? undefined} demandMap={pendingDemandMapPatch && demandMap ? { ...demandMap, ...pendingDemandMapPatch } : demandMap ?? undefined}
/> />
<div className="hb-radar-actions"> <div className="hb-radar-actions">
@ -472,12 +506,12 @@ export function RadarWatchesPage() {
{loading ? ( {loading ? (
<p className="text-muted">{t("common.loading")}</p> <p className="text-muted">{t("common.loading")}</p>
) : error ? null : watches.length === 0 ? ( ) : watches.length === 0 && !error ? (
<EmptyState <EmptyState
title={statusFilter ? t("radar.watches.emptyFiltered") : t("radar.watches.empty")} title={statusFilter ? t("radar.watches.emptyFiltered") : t("radar.watches.empty")}
description={statusFilter ? undefined : t("radar.watches.emptyHint")} description={statusFilter ? undefined : t("radar.watches.emptyHint")}
/> />
) : ( ) : watches.length === 0 ? null : (
<> <>
<div className="hb-radar-watch-list"> <div className="hb-radar-watch-list">
{watches.map((w) => ( {watches.map((w) => (

View File

@ -356,7 +356,7 @@ export function TodayPage() {
{ {
key: "opportunity", key: "opportunity",
done: onboardingOpportunityDone, done: onboardingOpportunityDone,
to: "/app/radar/today", to: "/app/radar",
label: t("today.onboarding.step.opportunity"), label: t("today.onboarding.step.opportunity"),
hint: t("today.onboarding.step.opportunityHint"), hint: t("today.onboarding.step.opportunityHint"),
}, },
@ -408,7 +408,7 @@ export function TodayPage() {
<span className="hb-today-metric__value">{radarToday.stats.low}</span> <span className="hb-today-metric__value">{radarToday.stats.low}</span>
</div> </div>
</div> </div>
<Link className="hb-btn hb-btn--secondary" to="/app/radar/today"> <Link className="hb-btn hb-btn--secondary" to="/app/radar">
{t("today.radar.open")} {t("today.radar.open")}
</Link> </Link>
</> </>

View File

@ -38,6 +38,52 @@
color: var(--hb-muted); color: var(--hb-muted);
} }
.hb-radar-patrol {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: var(--hb-space-4);
margin-bottom: var(--hb-space-5);
padding: var(--hb-space-4);
border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-lg);
background: var(--hb-surface);
}
.hb-radar-patrol__status {
display: flex;
flex-direction: column;
gap: var(--hb-space-2);
min-width: 0;
}
.hb-radar-patrol__status p {
display: flex;
flex-direction: column;
gap: 0.15rem;
margin: 0;
}
.hb-radar-patrol__status span {
font-size: var(--hb-text-sm);
color: var(--hb-muted);
}
.hb-radar-patrol__actions {
display: flex;
flex-wrap: wrap;
gap: var(--hb-space-2);
max-width: 25rem;
}
.hb-radar-patrol__actions small {
flex-basis: 100%;
color: var(--hb-muted);
font-size: var(--hb-text-xs);
line-height: 1.5;
}
/** 空狀態一律要說「為什麼空」與「下一步做什麼」,不是只寫「沒有資料」。 */ /** 空狀態一律要說「為什麼空」與「下一步做什麼」,不是只寫「沒有資料」。 */
.hb-radar-empty { .hb-radar-empty {
display: flex; display: flex;