2026-07-13 01:15:30 +00:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 03:18:08 +00:00
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 01:15:30 +00:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|