package usecase import ( "context" "fmt" "strings" "apps/backend/internal/module/radar/domain" ) // HealthGate checks AccountHealth before auto-send (outbox path). // Level: ok | warn | throttle. Throttle must block automatic send. type HealthGate interface { // WorstLevel returns the worst health level among usable accounts for owner. WorstLevel(ctx context.Context, ownerUID int64) (level string, advice string, err error) } // ReplyQueue puts a reply into the shared Outbox pipeline (same path as scout's // outreach send). Implemented by an adapter over studio.Service.QueueExternalReply. type ReplyQueue interface { QueueExternalReply(ctx context.Context, ownerUID int64, accountID, replyToMediaID, text, title string) (outboxID string, err error) } // MediaResolver turns a Threads permalink into a numeric Graph media ID. // Opportunity.ExternalID/Permalink is a permalink, not a media id — implemented // by an adapter that reuses scout's crawler session + resolver. type MediaResolver interface { ResolveMediaID(ctx context.Context, ownerUID int64, permalink string) (string, error) } /* MarkReplyUsed records that a draft was sent or copied (T550). channel: outbox | manual_copy - dm variants: only manual_copy - outbox: rejects when health=throttle; warn still allowed (caller may surface advice). accountID selects which usable Threads account sends; required when ReplyQueue is configured (production). When ReplyQueue is nil (offline tests/demo) marking still succeeds without a real send, matching the previous behaviour. */ func (s *Service) MarkReplyUsed(ctx context.Context, ownerUID int64, opportunityID, replyID, channel, accountID string) (*domain.ReplyVariant, string, error) { channel = strings.TrimSpace(channel) if channel != domain.SentOutbox && channel != domain.SentManualCopy { return nil, "", fmt.Errorf("%w: channel must be outbox or manual_copy", domain.ErrValidation) } o, err := s.GetOpportunity(ctx, ownerUID, opportunityID) if err != nil { return nil, "", err } r, err := s.Repo.GetReply(ctx, replyID) if err != nil { return nil, "", err } if r.OwnerUID != ownerUID || r.OpportunityID != opportunityID { return nil, "", domain.ErrForbidden } if r.Variant == domain.ReplyDM && channel == domain.SentOutbox { return nil, "", fmt.Errorf("%w: dm reply cannot auto-send; use manual_copy", domain.ErrValidation) } var healthAdvice string if channel == domain.SentOutbox { if s.Health == nil { // No gate wired: still allow mark-used so offline demos work; production wires Health. } else { level, advice, herr := s.Health.WorstLevel(ctx, ownerUID) if herr != nil { return nil, "", herr } if level == "throttle" { return nil, advice, fmt.Errorf("%w: account health throttle blocks auto-send; copy and send manually", domain.ErrValidation) } if level == "warn" { healthAdvice = advice if healthAdvice == "" { healthAdvice = "帳號健康度偏黃,建議放慢自動送出。" } } } if s.ReplyQueue != nil { accountID = strings.TrimSpace(accountID) if accountID == "" { return nil, "", fmt.Errorf("%w: account_id required to send via outbox", domain.ErrValidation) } mediaID := o.ExternalID if s.MediaResolver != nil { resolved, rerr := s.MediaResolver.ResolveMediaID(ctx, ownerUID, o.Permalink) if rerr != nil { return nil, "", rerr } mediaID = resolved } title := "商機回覆" if o.AuthorHandle != "" { title += " · @" + o.AuthorHandle } outboxID, serr := s.ReplyQueue.QueueExternalReply(ctx, ownerUID, accountID, mediaID, r.Text, title) if serr != nil { return nil, "", serr } r.OutboxID = outboxID } } r.UsedAt = domain.NowNano() r.SentChannel = channel if err := s.Repo.SaveReply(ctx, r); err != nil { return nil, "", err } return r, healthAdvice, nil }