package usecase import ( "context" "fmt" "strings" "apps/backend/internal/module/radar/domain" ) type WatchInput struct { Terms []string ExcludeTerms []string Regions []string BrandID string ProductID string // 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, } 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 } if err := w.Normalize(); err != nil { return nil, err } firstEverWatch := false if status == domain.WatchActive { if err := s.assertCanActivateForWatch(ctx, ownerUID, "", productWatch); err != nil { return nil, err } // 只在使用者從未建過任何訂閱時才判定「首巡」,避免每次新增都額外燒一次巡檢成本。 if _, total, err := s.Repo.ListWatches(ctx, ownerUID, domain.WatchListFilter{Page: 1, PageSize: 1}); err == nil { firstEverWatch = total == 0 } } 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 } } 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 只改關鍵字與地區;狀態一律走 Pause/Resume/Archive。 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 { if err := s.assertCanActivateForWatch(ctx, ownerUID, w.ID, w.ContextMode == domain.WatchContextProduct); err != nil { return nil, err } // 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 = "" } } return s.applyTransition(ctx, w, domain.WatchActive) } // 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 } // ArchiveWatch 是軟刪:歷史商機與統計都留著。 func (s *Service) ArchiveWatch(ctx context.Context, ownerUID int64, id string) error { _, err := s.transition(ctx, ownerUID, id, domain.WatchArchived) return err } // 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) } 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 }