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

145 lines
4.1 KiB
Go
Raw Normal View History

2026-08-03 05:52:02 +00:00
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", "這輪有巡但沒有符合的商機。可放寬關鍵字或檢查排除詞是否太嚴。"
}