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

252 lines
8.2 KiB
Go
Raw Permalink 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"
"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 只改關鍵字與地區;狀態一律走 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 {
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
}