package usecase import ( "context" "encoding/json" "fmt" "strings" "apps/backend/internal/module/job/domain" "github.com/google/uuid" ) // Notifier — 任務狀態寫入鈴鐺(同一 job upsert;含進度) type Notifier interface { // NotifyJobState templateType/status/summary/percent;progress 與 terminal 都走這條 NotifyJobState(ctx context.Context, ownerUID int64, jobID, templateType, status, summary string, percent int) error } type Service struct { Repo domain.Repository Notifier Notifier } func New(repo domain.Repository) *Service { return &Service{Repo: repo} } // StartDemo — JB-01 func (s *Service) StartDemo(ctx context.Context, ownerUID int64) (*domain.Job, error) { now := domain.NowNano() j := &domain.Job{ ID: uuid.NewString(), OwnerUID: ownerUID, TemplateType: domain.TemplateDemo, Status: domain.StatusPending, ProgressSummary: "Demo 測試任務已建立", ProgressPercent: 0, CreatedAt: now, UpdatedAt: now, } if err := s.Repo.Insert(ctx, j); err != nil { return nil, err } j.Status = domain.StatusQueued j.ProgressSummary = "Demo 測試任務 · 等待 worker 領取" j.UpdatedAt = domain.NowNano() _ = s.Repo.Update(ctx, j) s.notify(ctx, j) return j, nil } func (s *Service) List(ctx context.Context, ownerUID int64) ([]*domain.Job, error) { return s.Repo.ListByOwner(ctx, ownerUID) } // ListTab — 分頁+分桶(任務頁三頁籤) func (s *Service) ListTab(ctx context.Context, ownerUID int64, tab string, page, pageSize int) ([]*domain.Job, int64, error) { return s.Repo.ListByOwnerTab(ctx, ownerUID, tab, page, pageSize) } // Get — JB-09 func (s *Service) Get(ctx context.Context, ownerUID int64, id string) (*domain.Job, error) { j, err := s.Repo.FindByID(ctx, id) if err != nil { return nil, err } if j.OwnerUID != ownerUID { return nil, domain.ErrForbidden } return j, nil } // Transition — JB-10 func (s *Service) Transition(ctx context.Context, id, to, summary string, percent int) (*domain.Job, error) { j, err := s.Repo.FindByID(ctx, id) if err != nil { return nil, err } if !domain.CanTransition(j.Status, to) { return nil, domain.ErrIllegalStatus } if percent >= 0 { if err := domain.ApplyProgress(j, percent, summary); err != nil { return nil, err } } else if summary != "" { j.ProgressSummary = summary j.UpdatedAt = domain.NowNano() } j.Status = to if to == domain.StatusSucceeded || to == domain.StatusFailed || to == domain.StatusCancelled { j.CompletedAt = domain.NowNano() if to == domain.StatusFailed && j.Error == "" { j.Error = summary if j.Error == "" { j.Error = "failed" } } } if err := s.Repo.Update(ctx, j); err != nil { return nil, err } if s.Notifier != nil && (to == domain.StatusSucceeded || to == domain.StatusFailed || to == domain.StatusCancelled) { pct := j.ProgressPercent if to == domain.StatusSucceeded { pct = 100 } _ = s.Notifier.NotifyJobState(ctx, j.OwnerUID, j.ID, j.TemplateType, to, j.ProgressSummary, pct) } return j, nil } func (s *Service) ClaimNext(ctx context.Context, workerID string) (*domain.Job, error) { j, err := s.Repo.ClaimNext(ctx, workerID) if err != nil { return nil, err } // claim → running:立刻寫鈴鐺/列表進度(所有模板共用) if j.ProgressPercent < 5 { j.ProgressPercent = 5 } if j.ProgressSummary == "" || strings.Contains(j.ProgressSummary, "worker 領取") { j.ProgressSummary = "已由 worker 領取 · 執行中" } j.UpdatedAt = domain.NowNano() _ = s.Repo.Update(ctx, j) s.notify(ctx, j) return j, nil } // RunDemoToSuccess advances running job with monotonic progress then succeeded. // 每一步都 MarkRunningProgress → 鈴鐺/前端可即時看到 %。 func (s *Service) RunDemoToSuccess(ctx context.Context, jobID string) (*domain.Job, error) { j, err := s.Repo.FindByID(ctx, jobID) if err != nil { return nil, err } // ensure running(未 claim 時) if j.Status == domain.StatusPending || j.Status == domain.StatusQueued { if !domain.CanTransition(j.Status, domain.StatusRunning) { return nil, domain.ErrIllegalStatus } j.Status = domain.StatusRunning j.ProgressSummary = "Demo 測試任務 · 執行中" j.ProgressPercent = 5 j.UpdatedAt = domain.NowNano() _ = s.Repo.Update(ctx, j) s.notify(ctx, j) } if j.Status != domain.StatusRunning { return nil, domain.ErrIllegalStatus } for _, step := range []struct { pct int sum string }{ {30, "Demo 測試任務 · 暖機中"}, {70, "Demo 測試任務 · 處理中"}, {95, "Demo 測試任務 · 收尾中"}, } { if _, err := s.MarkRunningProgress(ctx, jobID, step.pct, step.sum); err != nil { return nil, err } } return s.SucceedJob(ctx, jobID, "Demo 測試任務 · 已完成") } // ScheduleTokenRenew creates a delayed job to refresh Threads account token (no browser OAuth). // Previous pending renews for the same account are cancelled. func (s *Service) ScheduleTokenRenew(ctx context.Context, ownerUID int64, accountID string, runAfter int64) error { if ownerUID <= 0 || accountID == "" { return domain.ErrForbidden } now := domain.NowNano() if runAfter <= 0 { runAfter = now + domain.DefaultTokenRenewDelayNs } _ = s.Repo.CancelPendingByRef(ctx, ownerUID, domain.TemplateThreadsTokenRenew, accountID) j := &domain.Job{ ID: uuid.NewString(), OwnerUID: ownerUID, TemplateType: domain.TemplateThreadsTokenRenew, Status: domain.StatusQueued, RefID: accountID, RunAfter: runAfter, ProgressSummary: "定期延長 Threads token(約每 30 天)· 已排程等待執行", ProgressPercent: 0, CreatedAt: now, UpdatedAt: now, } if err := s.Repo.Insert(ctx, j); err != nil { return err } // 與其他任務相同:入列就寫一則通知(延遲執行仍會在任務列表可見) s.notify(ctx, j) return nil } // Ensure job.Service implements TokenRenewScheduler (threads package). var _ interface { ScheduleTokenRenew(ctx context.Context, ownerUID int64, accountID string, runAfter int64) error } = (*Service)(nil) // PersonaAnalyzeAccountPayload — worker 爬公開貼文並分析 type PersonaAnalyzeAccountPayload struct { Username string `json:"username"` Lang string `json:"lang,omitempty"` } // PersonaAnalyzeTextPayload — worker 從貼文文字分析 type PersonaAnalyzeTextPayload struct { RawText string `json:"raw_text"` SourceLabel string `json:"source_label,omitempty"` Lang string `json:"lang,omitempty"` } // SchedulePersonaAnalyzeAccount — 立刻可領;ref=personaID;同人設舊佇列取消 func (s *Service) SchedulePersonaAnalyzeAccount(ctx context.Context, ownerUID int64, personaID, username, lang string) (*domain.Job, error) { if ownerUID <= 0 || personaID == "" { return nil, domain.ErrForbidden } username = strings.TrimPrefix(strings.TrimSpace(username), "@") if username == "" { return nil, fmt.Errorf("empty username") } _ = s.Repo.CancelPendingByRef(ctx, ownerUID, domain.TemplatePersonaAnalyzeAccount, personaID) _ = s.Repo.CancelPendingByRef(ctx, ownerUID, domain.TemplatePersonaAnalyzeText, personaID) body, _ := json.Marshal(PersonaAnalyzeAccountPayload{Username: username, Lang: lang}) now := domain.NowNano() j := &domain.Job{ ID: uuid.NewString(), OwnerUID: ownerUID, TemplateType: domain.TemplatePersonaAnalyzeAccount, Status: domain.StatusQueued, RefID: personaID, Payload: string(body), ProgressSummary: fmt.Sprintf("人設分析 · 爬取 @%s 公開貼文 · 已排程", username), ProgressPercent: 0, CreatedAt: now, UpdatedAt: now, } if err := s.Repo.Insert(ctx, j); err != nil { return nil, err } s.notify(ctx, j) return j, nil } // ComposeMimicPayload — worker 仿寫輸入/輸出 type ComposeMimicPayload struct { SourceText string `json:"source_text"` PersonaID string `json:"persona_id,omitempty"` StructureNotes string `json:"structure_notes,omitempty"` Lang string `json:"lang,omitempty"` // ResultText 成功後寫回 ResultText string `json:"result_text,omitempty"` } // ScheduleComposeMimic — 立刻可領;仿寫走背景 job,避免 HTTP 120s 逾時 func (s *Service) ScheduleComposeMimic(ctx context.Context, ownerUID int64, sourceText, personaID, structureNotes, lang string) (*domain.Job, error) { if ownerUID <= 0 { return nil, domain.ErrForbidden } sourceText = strings.TrimSpace(sourceText) if sourceText == "" { return nil, fmt.Errorf("empty source") } // 同使用者只保留一則進行中的仿寫(可選:不 cancel 舊的也可) body, _ := json.Marshal(ComposeMimicPayload{ SourceText: sourceText, PersonaID: strings.TrimSpace(personaID), StructureNotes: strings.TrimSpace(structureNotes), Lang: lang, }) now := domain.NowNano() j := &domain.Job{ ID: uuid.NewString(), OwnerUID: ownerUID, TemplateType: domain.TemplateComposeMimic, Status: domain.StatusQueued, RefID: strings.TrimSpace(personaID), Payload: string(body), ProgressSummary: "仿寫貼文 · 已排程(可離開頁面)", ProgressPercent: 0, CreatedAt: now, UpdatedAt: now, } if err := s.Repo.Insert(ctx, j); err != nil { return nil, err } s.notify(ctx, j) return j, nil } // PlayGenerateScriptPayload — worker 一次產完整互回劇本 type PlayGenerateScriptPayload struct { PlayID string `json:"play_id"` OnlyEmpty bool `json:"only_empty"` // 成功後統計 Filled int `json:"filled,omitempty"` } // SchedulePlayGenerateScript — 立刻可領;ref=playID;同劇本舊佇列取消 func (s *Service) SchedulePlayGenerateScript(ctx context.Context, ownerUID int64, playID string, onlyEmpty bool) (*domain.Job, error) { if ownerUID <= 0 || strings.TrimSpace(playID) == "" { return nil, domain.ErrForbidden } playID = strings.TrimSpace(playID) _ = s.Repo.CancelPendingByRef(ctx, ownerUID, domain.TemplatePlayGenerateScript, playID) body, _ := json.Marshal(PlayGenerateScriptPayload{PlayID: playID, OnlyEmpty: onlyEmpty}) now := domain.NowNano() j := &domain.Job{ ID: uuid.NewString(), OwnerUID: ownerUID, TemplateType: domain.TemplatePlayGenerateScript, Status: domain.StatusQueued, RefID: playID, Payload: string(body), ProgressSummary: "劇本 AI 產文 · 已排程(一次產全部步驟,可離開頁面)", ProgressPercent: 0, CreatedAt: now, UpdatedAt: now, } if err := s.Repo.Insert(ctx, j); err != nil { return nil, err } s.notify(ctx, j) return j, nil } // SucceedJobWithPayload — 終態成功並寫回 payload(仿寫結果) func (s *Service) SucceedJobWithPayload(ctx context.Context, jobID, summary, payload string) (*domain.Job, error) { j, err := s.Repo.FindByID(ctx, jobID) if err != nil { return nil, err } if !domain.CanTransition(j.Status, domain.StatusSucceeded) { return nil, domain.ErrIllegalStatus } if payload != "" { j.Payload = payload } if err := domain.ApplyProgress(j, 100, summary); err != nil { return nil, err } j.Status = domain.StatusSucceeded j.CompletedAt = domain.NowNano() j.UpdatedAt = j.CompletedAt if err := s.Repo.Update(ctx, j); err != nil { return nil, err } s.notify(ctx, j) return j, nil } // SchedulePersonaAnalyzeText — 立刻可領;ref=personaID func (s *Service) SchedulePersonaAnalyzeText(ctx context.Context, ownerUID int64, personaID, rawText, sourceLabel, lang string) (*domain.Job, error) { if ownerUID <= 0 || personaID == "" { return nil, domain.ErrForbidden } rawText = strings.TrimSpace(rawText) if rawText == "" { return nil, fmt.Errorf("empty text") } _ = s.Repo.CancelPendingByRef(ctx, ownerUID, domain.TemplatePersonaAnalyzeAccount, personaID) _ = s.Repo.CancelPendingByRef(ctx, ownerUID, domain.TemplatePersonaAnalyzeText, personaID) body, _ := json.Marshal(PersonaAnalyzeTextPayload{ RawText: rawText, SourceLabel: strings.TrimSpace(sourceLabel), Lang: lang, }) now := domain.NowNano() label := strings.TrimSpace(sourceLabel) if label == "" { label = "手動貼文" } j := &domain.Job{ ID: uuid.NewString(), OwnerUID: ownerUID, TemplateType: domain.TemplatePersonaAnalyzeText, Status: domain.StatusQueued, RefID: personaID, Payload: string(body), ProgressSummary: fmt.Sprintf("人設分析 · 文字來源「%s」· 已排程", label), ProgressPercent: 0, CreatedAt: now, UpdatedAt: now, } if err := s.Repo.Insert(ctx, j); err != nil { return nil, err } s.notify(ctx, j) return j, nil } // RunTokenRenew executes threads_token_renew job body (caller provides refresh fn). func (s *Service) MarkRunningProgress(ctx context.Context, jobID string, pct int, summary string) (*domain.Job, error) { j, err := s.Repo.FindByID(ctx, jobID) if err != nil { return nil, err } if j.Status != domain.StatusRunning { return nil, domain.ErrIllegalStatus } if err := domain.ApplyProgress(j, pct, summary); err != nil { return nil, err } if err := s.Repo.Update(ctx, j); err != nil { return nil, err } // 每次有進度就更新鈴鐺(upsert 同一則) s.notify(ctx, j) return j, nil } func (s *Service) notify(ctx context.Context, j *domain.Job) { if s.Notifier == nil || j == nil { return } _ = s.Notifier.NotifyJobState(ctx, j.OwnerUID, j.ID, j.TemplateType, j.Status, j.ProgressSummary, j.ProgressPercent) } func (s *Service) SucceedJob(ctx context.Context, jobID, summary string) (*domain.Job, error) { return s.Transition(ctx, jobID, domain.StatusSucceeded, summary, 100) } // Delete — 擁有者手動刪除;執行中不可刪。 func (s *Service) Delete(ctx context.Context, ownerUID int64, id string) error { j, err := s.Repo.FindByID(ctx, id) if err != nil { return err } if j.OwnerUID != ownerUID { return domain.ErrForbidden } if j.Status == domain.StatusRunning { return domain.ErrIllegalStatus } return s.Repo.Delete(ctx, id) } // PurgeExpiredTerminal — 清除終態超過保留期(預設 2 天)的任務。回傳刪除筆數。 func (s *Service) PurgeExpiredTerminal(ctx context.Context) (int64, error) { return s.Repo.DeleteTerminalBefore(ctx, domain.TerminalCutoff(domain.NowNano())) } // FailJob — JB-04 func (s *Service) FailJob(ctx context.Context, jobID, errMsg string) (*domain.Job, error) { j, err := s.Repo.FindByID(ctx, jobID) if err != nil { return nil, err } if j.Status == domain.StatusPending || j.Status == domain.StatusQueued { j.Status = domain.StatusRunning j.UpdatedAt = domain.NowNano() _ = s.Repo.Update(ctx, j) } if !domain.CanTransition(j.Status, domain.StatusFailed) { return nil, domain.ErrIllegalStatus } j.Status = domain.StatusFailed j.Error = errMsg if errMsg != "" { j.ProgressSummary = "失敗:" + errMsg if len([]rune(j.ProgressSummary)) > 200 { j.ProgressSummary = string([]rune(j.ProgressSummary)[:200]) + "…" } } else { j.ProgressSummary = "失敗" } j.CompletedAt = domain.NowNano() j.UpdatedAt = j.CompletedAt if err := s.Repo.Update(ctx, j); err != nil { return nil, err } s.notify(ctx, j) return j, nil }