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

145 lines
4.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

package usecase
import (
"context"
"errors"
"fmt"
"apps/backend/internal/module/radar/domain"
)
// TodayStats is the high/mid/low count block for the today page.
type TodayStats struct {
Total int
High int
Mid int
Low int
}
// TodayOpportunity is one card on the today page (opp + optional default reply).
type TodayOpportunity struct {
Opportunity *domain.Opportunity
DefaultReply *domain.ReplyVariant
}
// TodayResult powers GET /radar/today.
type TodayResult struct {
Stats TodayStats
High []TodayOpportunity
Mid []TodayOpportunity
Low []TodayOpportunity
TruncatedCount int
LastSweptAt int64
EmptyReason string
EmptyHint string
}
// todayListStatuses今日名單含可操作與已處理排除 rejectedjudgingT545
var todayListStatuses = []string{domain.OppQualified, domain.OppAccepted, domain.OppDismissed}
func (s *Service) GetToday(ctx context.Context, ownerUID int64) (*TodayResult, error) {
if ownerUID <= 0 {
return nil, fmt.Errorf("%w: owner_uid required", domain.ErrValidation)
}
start, end := domain.UTCDayBounds(domain.NowNano())
// Empty-state diagnostics只有「真的沒建檔」才算 no_profile其他 DB 錯誤要往上丟。
_, profileErr := s.Repo.GetServiceProfile(ctx, ownerUID)
noProfile := errors.Is(profileErr, domain.ErrNotFound)
if profileErr != nil && !noProfile {
return nil, profileErr
}
watches, _, err := s.Repo.ListWatches(ctx, ownerUID, domain.WatchListFilter{Page: 1, PageSize: 50})
if err != nil {
return nil, err
}
active, err := s.Repo.ListActiveWatches(ctx, ownerUID)
if err != nil {
return nil, err
}
var lastSwept int64
for _, w := range watches {
if w.LastSweptAt > lastSwept {
lastSwept = w.LastSweptAt
}
}
list, _, err := s.Repo.ListOpportunities(ctx, ownerUID, domain.OpportunityListFilter{
Statuses: todayListStatuses,
CreatedFrom: start,
CreatedTo: end,
Page: 1,
PageSize: 100,
})
if err != nil {
return nil, err
}
high, mid, low := []TodayOpportunity{}, []TodayOpportunity{}, []TodayOpportunity{}
for _, o := range list {
card := TodayOpportunity{Opportunity: o}
if replies, rerr := s.Repo.ListReplies(ctx, ownerUID, o.ID); rerr == nil {
for _, r := range replies {
if r.Variant == domain.ReplyPublicComment {
card.DefaultReply = r
break
}
}
}
switch o.IntentBand {
case domain.BandHigh:
high = append(high, card)
case domain.BandMid:
mid = append(mid, card)
default:
low = append(low, card)
}
}
total := len(high) + len(mid) + len(low)
// truncated from today's sweeps
sweeps, _, _ := s.Repo.ListSweeps(ctx, ownerUID, domain.SweepListFilter{Page: 1, PageSize: 20})
trunc := 0
var latestFail string
for _, sw := range sweeps {
if sw.StartedAt >= start && sw.StartedAt < end {
trunc += sw.TruncatedCount
if sw.FailedReason != "" {
latestFail = sw.FailedReason
}
}
}
out := &TodayResult{
Stats: TodayStats{Total: total, High: len(high), Mid: len(mid), Low: len(low)},
High: high,
Mid: mid,
Low: low,
TruncatedCount: trunc,
LastSweptAt: lastSwept,
}
if total == 0 {
out.EmptyReason, out.EmptyHint = emptyReason(noProfile, len(watches), len(active), lastSwept, latestFail, start)
}
return out, nil
}
func emptyReason(noProfile bool, watchCount, activeCount int, lastSwept int64, fail string, dayStart int64) (reason, hint string) {
if noProfile {
return "no_profile", "先完成服務檔案,雷達才能判定適不適合你的服務。"
}
if watchCount == 0 {
return "no_watch", "建立至少一組關鍵字訂閱,明天早晨就會開始巡。"
}
if activeCount == 0 {
return "all_watches_paused", "目前沒有啟用中的訂閱。恢復一組訂閱後才會繼續巡。"
}
if fail != "" {
return "sweep_failed", fail
}
if lastSwept < dayStart {
return "not_swept_yet", "今日巡檢還沒跑完(每日 UTC 22:00 開始)。也可在訂閱頁手動觸發。"
}
return "no_hit", "這輪有巡但沒有符合的商機。可放寬關鍵字或檢查排除詞是否太嚴。"
}