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

252 lines
8.3 KiB
Go
Raw Permalink Normal View History

2026-08-03 05:52:02 +00:00
package usecase
import (
"context"
"fmt"
2026-08-13 02:22:24 +00:00
"strings"
2026-08-03 05:52:02 +00:00
"apps/backend/internal/module/radar/domain"
)
type WatchInput struct {
Terms []string
ExcludeTerms []string
Regions []string
2026-08-13 02:22:24 +00:00
BrandID string
ProductID string
2026-08-03 05:52:02 +00:00
// Enabled=false 代表建立成 paused可先備好關鍵字再開。
Enabled bool
}
type WatchPatch struct {
Terms *[]string
ExcludeTerms *[]string
Regions *[]string
}
func (s *Service) CreateWatch(ctx context.Context, ownerUID int64, in WatchInput) (*domain.RadarWatch, error) {
if ownerUID <= 0 {
return nil, fmt.Errorf("%w: owner_uid required", domain.ErrValidation)
}
status := domain.WatchPaused
if in.Enabled {
status = domain.WatchActive
}
now := domain.NowNano()
w := &domain.RadarWatch{
ID: domain.NewID(),
OwnerUID: ownerUID,
Terms: in.Terms,
ExcludeTerms: in.ExcludeTerms,
Regions: in.Regions,
Status: status,
CreatedAt: now,
UpdatedAt: now,
}
2026-08-13 02:22:24 +00:00
brandID, productID := strings.TrimSpace(in.BrandID), strings.TrimSpace(in.ProductID)
if (brandID == "") != (productID == "") {
return nil, fmt.Errorf("%w: brand_id and product_id must be provided together", domain.ErrValidation)
}
productWatch := brandID != ""
if productWatch {
product, err := s.LoadProductContext(ctx, ownerUID, brandID, productID)
if err != nil {
return nil, err
}
w.ContextMode, w.BrandID, w.ProductID = domain.WatchContextProduct, product.BrandID, product.ProductID
w.BrandNameSnapshot, w.ProductLabelSnapshot, w.ContextBoundAt = product.BrandName, product.ProductLabel, now
}
2026-08-03 05:52:02 +00:00
if err := w.Normalize(); err != nil {
return nil, err
}
firstEverWatch := false
2026-08-03 05:52:02 +00:00
if status == domain.WatchActive {
2026-08-13 02:22:24 +00:00
if err := s.assertCanActivateForWatch(ctx, ownerUID, "", productWatch); err != nil {
2026-08-03 05:52:02 +00:00
return nil, err
}
// 只在使用者從未建過任何訂閱時才判定「首巡」,避免每次新增都額外燒一次巡檢成本。
if _, total, err := s.Repo.ListWatches(ctx, ownerUID, domain.WatchListFilter{Page: 1, PageSize: 1}); err == nil {
firstEverWatch = total == 0
}
2026-08-03 05:52:02 +00:00
}
if err := s.Repo.SaveWatch(ctx, w); err != nil {
return nil, err
}
if firstEverWatch && s.SweepJobs != nil {
// 不等每日排程:讓第一組訂閱的使用者最快隔一小段時間就看到結果,而不是等到隔天。
// 失敗不阻斷建立,每日排程仍會補上。
if _, sweepErr := s.SweepJobs.ScheduleRadarSweep(ctx, ownerUID, w.ID, domain.NowNano()); sweepErr == nil {
w.FirstSweepTriggered = true
}
}
2026-08-03 05:52:02 +00:00
return w, nil
}
// GetWatch 以 owner 檢查取代單純 id 查詢id 是 uuid但不該靠不可預測性當授權。
func (s *Service) GetWatch(ctx context.Context, ownerUID int64, id string) (*domain.RadarWatch, error) {
w, err := s.Repo.GetWatch(ctx, id)
if err != nil {
return nil, err
}
if w.OwnerUID != ownerUID {
return nil, domain.ErrForbidden
}
return w, nil
}
func (s *Service) ListWatches(ctx context.Context, ownerUID int64, f domain.WatchListFilter) ([]*domain.RadarWatch, int64, error) {
if ownerUID <= 0 {
return nil, 0, fmt.Errorf("%w: owner_uid required", domain.ErrValidation)
}
if f.Status != "" && !domain.IsWatchStatus(f.Status) {
return nil, 0, fmt.Errorf("%w: unknown status filter %q", domain.ErrValidation, f.Status)
}
if f.PageSize > 50 {
f.PageSize = 50
}
return s.Repo.ListWatches(ctx, ownerUID, f)
}
func (s *Service) ListActiveWatches(ctx context.Context, ownerUID int64) ([]*domain.RadarWatch, error) {
return s.Repo.ListActiveWatches(ctx, ownerUID)
}
func (s *Service) CountActiveWatches(ctx context.Context, ownerUID int64) (int64, error) {
return s.Repo.CountActiveWatches(ctx, ownerUID)
}
/*
UpdateWatch 只改關鍵字與地區狀態一律走 PauseResumeArchive
nil 欄位代表不動這樣只改地區不會意外清空關鍵字
*/
func (s *Service) UpdateWatch(ctx context.Context, ownerUID int64, id string, patch WatchPatch) (*domain.RadarWatch, error) {
w, err := s.GetWatch(ctx, ownerUID, id)
if err != nil {
return nil, err
}
if w.Status == domain.WatchArchived {
return nil, fmt.Errorf("%w: archived watch cannot be edited", domain.ErrValidation)
}
if patch.Terms != nil {
w.Terms = *patch.Terms
}
if patch.ExcludeTerms != nil {
w.ExcludeTerms = *patch.ExcludeTerms
}
if patch.Regions != nil {
w.Regions = *patch.Regions
}
if err := w.Normalize(); err != nil {
return nil, err
}
w.UpdatedAt = domain.NowNano()
if err := s.Repo.SaveWatch(ctx, w); err != nil {
return nil, err
}
return w, nil
}
func (s *Service) PauseWatch(ctx context.Context, ownerUID int64, id string) (*domain.RadarWatch, error) {
return s.transition(ctx, ownerUID, id, domain.WatchPaused)
}
/*
ResumeWatch 回到 active因此要再過一次配額與服務檔案閘暫停期間方案可能已降級
不重驗就會讓人靠暫停再恢復繞過上限
*/
func (s *Service) ResumeWatch(ctx context.Context, ownerUID int64, id string) (*domain.RadarWatch, error) {
w, err := s.GetWatch(ctx, ownerUID, id)
if err != nil {
return nil, err
}
if w.Status != domain.WatchActive {
2026-08-13 02:22:24 +00:00
if err := s.assertCanActivateForWatch(ctx, ownerUID, w.ID, w.ContextMode == domain.WatchContextProduct); err != nil {
2026-08-03 05:52:02 +00:00
return nil, err
}
2026-08-13 02:22:24 +00:00
// An unavailable-product pause is normally terminal, but a transient
// catalog/worker wiring outage must not strand a valid watch forever.
// Revalidate the current owned Brand/Product before clearing the guard;
// a genuinely deleted or mismatched product still cannot resume.
if w.PauseReason == domain.PauseReasonProductUnavailable || w.PauseReason == domain.PauseReasonBrandUnavailable {
if w.ContextMode != domain.WatchContextProduct {
return nil, fmt.Errorf("%w: unavailable watch has no product context", domain.ErrValidation)
}
if _, err := s.LoadProductContext(ctx, ownerUID, w.BrandID, w.ProductID); err != nil {
return nil, err
}
w.PauseReason = ""
}
2026-08-03 05:52:02 +00:00
}
return s.applyTransition(ctx, w, domain.WatchActive)
}
2026-08-13 02:22:24 +00:00
// AssignWatchProduct is the one-way migration from a generic watch to an
// owned Brand/Product context. An already-bound product cannot be replaced.
func (s *Service) AssignWatchProduct(ctx context.Context, ownerUID int64, id, brandID, productID string) (*domain.RadarWatch, error) {
w, err := s.GetWatch(ctx, ownerUID, id)
if err != nil {
return nil, err
}
brandID, productID = strings.TrimSpace(brandID), strings.TrimSpace(productID)
if brandID == "" || productID == "" {
return nil, fmt.Errorf("%w: brand_id and product_id required", domain.ErrValidation)
}
product, err := s.LoadProductContext(ctx, ownerUID, brandID, productID)
if err != nil {
return nil, err
}
if err := w.BindProduct(product.BrandID, product.ProductID, product.BrandName, product.ProductLabel, domain.NowNano()); err != nil {
return nil, err
}
if err := s.Repo.SaveWatch(ctx, w); err != nil {
return nil, err
}
return w, nil
}
2026-08-03 05:52:02 +00:00
// ArchiveWatch 是軟刪:歷史商機與統計都留著。
func (s *Service) ArchiveWatch(ctx context.Context, ownerUID int64, id string) error {
_, err := s.transition(ctx, ownerUID, id, domain.WatchArchived)
return err
}
2026-08-13 02:22:24 +00:00
// DeleteArchivedWatch permanently removes only the archived watch definition.
// Sweeps, opportunities, and replies are deliberately retained as history;
// deleting a watch must never erase business records produced by it.
func (s *Service) DeleteArchivedWatch(ctx context.Context, ownerUID int64, id string) error {
w, err := s.GetWatch(ctx, ownerUID, id)
if err != nil {
return err
}
if w.Status != domain.WatchArchived {
return fmt.Errorf("%w: only archived watch can be permanently deleted", domain.ErrValidation)
}
return s.Repo.DeleteWatch(ctx, id)
}
2026-08-03 05:52:02 +00:00
func (s *Service) MarkWatchSwept(ctx context.Context, id string, at int64) error {
if at <= 0 {
at = domain.NowNano()
}
return s.Repo.TouchWatchSweptAt(ctx, id, at)
}
func (s *Service) transition(ctx context.Context, ownerUID int64, id, to string) (*domain.RadarWatch, error) {
w, err := s.GetWatch(ctx, ownerUID, id)
if err != nil {
return nil, err
}
return s.applyTransition(ctx, w, to)
}
func (s *Service) applyTransition(ctx context.Context, w *domain.RadarWatch, to string) (*domain.RadarWatch, error) {
if err := w.Transition(to); err != nil {
return nil, err
}
if err := s.Repo.SaveWatch(ctx, w); err != nil {
return nil, err
}
return w, nil
}