package usecase import ( "context" "strings" "time" "haixun-backend/internal/library/clock" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" libthreads "haixun-backend/internal/library/threadsapi" "haixun-backend/internal/library/threadspost" padomain "haixun-backend/internal/model/publish_analytics/domain/usecase" "haixun-backend/internal/model/publish_queue/domain/entity" domrepo "haixun-backend/internal/model/publish_queue/domain/repository" domusecase "haixun-backend/internal/model/publish_queue/domain/usecase" threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" "github.com/google/uuid" ) type publishQueueUseCase struct { repo domrepo.Repository threadsAccount threadsaccountdomain.UseCase publishAnalytics padomain.UseCase } func NewUseCase( repo domrepo.Repository, threadsAccount threadsaccountdomain.UseCase, publishAnalytics padomain.UseCase, ) domusecase.UseCase { return &publishQueueUseCase{ repo: repo, threadsAccount: threadsAccount, publishAnalytics: publishAnalytics, } } func (u *publishQueueUseCase) Create(ctx context.Context, req domusecase.CreateRequest) (*domusecase.QueueItemSummary, error) { tenantID := strings.TrimSpace(req.TenantID) ownerUID := strings.TrimSpace(req.OwnerUID) accountID := strings.TrimSpace(req.AccountID) text := strings.TrimSpace(req.Text) if tenantID == "" || ownerUID == "" { return nil, app.For(code.Auth).AuthUnauthorized("missing actor") } if accountID == "" { return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id is required") } if text == "" { return nil, app.For(code.ThreadsAccount).InputMissingRequired("text is required") } if err := threadspost.ValidatePublish(text); err != nil { return nil, app.For(code.ThreadsAccount).InputInvalidFormat(err.Error()) } if _, err := u.threadsAccount.Get(ctx, tenantID, ownerUID, accountID); err != nil { return nil, err } now := clock.NowUnixNano() scheduledAt := req.ScheduledAt if scheduledAt <= 0 { scheduledAt = now } item := &entity.QueueItem{ ID: uuid.NewString(), TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID, Text: text, ScheduledAt: scheduledAt, Status: entity.StatusScheduled, CreateAt: now, UpdateAt: now, } if err := u.repo.Create(ctx, item); err != nil { return nil, err } if scheduledAt <= now { _, _ = u.DispatchDue(ctx, 1) fresh, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, item.ID) if err == nil && fresh != nil { out := toSummary(*fresh) return &out, nil } } out := toSummary(*item) return &out, nil } func (u *publishQueueUseCase) RecordPublished(ctx context.Context, req domusecase.RecordPublishedRequest) (*domusecase.QueueItemSummary, error) { tenantID := strings.TrimSpace(req.TenantID) ownerUID := strings.TrimSpace(req.OwnerUID) accountID := strings.TrimSpace(req.AccountID) mediaID := strings.TrimSpace(req.MediaID) if err := requireActor(tenantID, ownerUID); err != nil { return nil, err } if accountID == "" || mediaID == "" { return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id and media_id are required") } if _, err := u.threadsAccount.Get(ctx, tenantID, ownerUID, accountID); err != nil { return nil, err } now := clock.NowUnixNano() publishedAt := req.PublishedAt if publishedAt <= 0 { publishedAt = now } item := &entity.QueueItem{ ID: uuid.NewString(), TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID, Text: strings.TrimSpace(req.Text), ScheduledAt: publishedAt, Status: entity.StatusPublished, MediaID: mediaID, Permalink: strings.TrimSpace(req.Permalink), PublishedAt: publishedAt, CreateAt: now, UpdateAt: now, } if err := u.repo.Create(ctx, item); err != nil { return nil, err } _ = u.publishAnalytics.ScheduleCheckpoints(ctx, padomain.ScheduleCheckpointsRequest{ TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID, MediaID: mediaID, Permalink: item.Permalink, PublishedAt: publishedAt, }) out := toSummary(*item) return &out, nil } func (u *publishQueueUseCase) Get(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*domusecase.QueueItemSummary, error) { if err := requireActor(tenantID, ownerUID); err != nil { return nil, err } item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, queueID) if err != nil { return nil, err } out := toSummary(*item) return &out, nil } func (u *publishQueueUseCase) List(ctx context.Context, req domusecase.ListRequest) (*domusecase.ListResult, error) { if err := requireActor(req.TenantID, req.OwnerUID); err != nil { return nil, err } page, pageSize := req.Page, req.PageSize if page <= 0 { page = 1 } if pageSize <= 0 { pageSize = 10 } if pageSize > 50 { pageSize = 50 } result, err := u.repo.List(ctx, domrepo.ListFilter{ TenantID: req.TenantID, OwnerUID: req.OwnerUID, AccountID: req.AccountID, Status: req.Status, }, page, pageSize) if err != nil { return nil, err } items := make([]domusecase.QueueItemSummary, 0, len(result.Items)) for _, item := range result.Items { items = append(items, toSummary(item)) } totalPages := 0 if pageSize > 0 { totalPages = int((result.Total + int64(pageSize) - 1) / int64(pageSize)) } return &domusecase.ListResult{ Items: items, Total: result.Total, Page: page, PageSize: pageSize, TotalPages: totalPages, }, nil } func (u *publishQueueUseCase) Update(ctx context.Context, req domusecase.UpdateRequest) (*domusecase.QueueItemSummary, error) { if err := requireActor(req.TenantID, req.OwnerUID); err != nil { return nil, err } item, err := u.repo.Get(ctx, req.TenantID, req.OwnerUID, req.AccountID, req.QueueID) if err != nil { return nil, err } if item.Status != entity.StatusScheduled { return nil, app.For(code.ThreadsAccount).ResInvalidState("僅排程中的項目可修改") } if req.Text != nil { text := strings.TrimSpace(*req.Text) if text == "" { return nil, app.For(code.ThreadsAccount).InputMissingRequired("text is required") } if err := threadspost.ValidatePublish(text); err != nil { return nil, app.For(code.ThreadsAccount).InputInvalidFormat(err.Error()) } item.Text = text } if req.ScheduledAt != nil && *req.ScheduledAt > 0 { item.ScheduledAt = *req.ScheduledAt } item.UpdateAt = clock.NowUnixNano() updated, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusScheduled}) if err != nil { return nil, err } out := toSummary(*updated) return &out, nil } func (u *publishQueueUseCase) Cancel(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*domusecase.QueueItemSummary, error) { if err := requireActor(tenantID, ownerUID); err != nil { return nil, err } item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, queueID) if err != nil { return nil, err } if item.Status != entity.StatusScheduled { return nil, app.For(code.ThreadsAccount).ResInvalidState("僅排程中的項目可取消") } item.Status = entity.StatusCancelled item.UpdateAt = clock.NowUnixNano() updated, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusScheduled}) if err != nil { return nil, err } out := toSummary(*updated) return &out, nil } func (u *publishQueueUseCase) Delete(ctx context.Context, tenantID, ownerUID, accountID, queueID string) error { if err := requireActor(tenantID, ownerUID); err != nil { return err } item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, queueID) if err != nil { return err } switch item.Status { case entity.StatusPublishing: return app.For(code.ThreadsAccount).ResInvalidState("發佈進行中,無法刪除") case entity.StatusPublished: return app.For(code.ThreadsAccount).ResInvalidState("已發佈項目不可刪除") case entity.StatusScheduled: _, err = u.Cancel(ctx, tenantID, ownerUID, accountID, queueID) return err default: return u.repo.Delete(ctx, tenantID, ownerUID, accountID, queueID) } } func (u *publishQueueUseCase) DispatchDue(ctx context.Context, limit int) (int, error) { if u.repo == nil { return 0, nil } now := clock.NowUnixNano() due, err := u.repo.ListDue(ctx, now, limit) if err != nil { return 0, err } dispatched := 0 for _, item := range due { if err := u.dispatchOne(ctx, &item); err != nil { continue } dispatched++ } return dispatched, nil } func (u *publishQueueUseCase) dispatchOne(ctx context.Context, item *entity.QueueItem) error { item.Status = entity.StatusPublishing item.UpdateAt = clock.NowUnixNano() claimed, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusScheduled}) if err != nil { return err } account, err := u.threadsAccount.Get(ctx, claimed.TenantID, claimed.OwnerUID, claimed.AccountID) if err != nil { return u.markFailed(ctx, claimed, "取得帳號失敗:"+err.Error()) } token, err := u.threadsAccount.ResolveAccountAPIAccessToken(ctx, claimed.TenantID, claimed.OwnerUID, claimed.AccountID) if err != nil { return u.markFailed(ctx, claimed, err.Error()) } userID := strings.TrimSpace(account.ThreadsUserID) if userID == "" || token == "" { return u.markFailed(ctx, claimed, "Threads API 未連線") } result, err := libthreads.PublishText(ctx, libthreads.PublishTextInput{ ThreadsUserID: userID, AccessToken: token, Text: claimed.Text, }) if err != nil { return u.markFailed(ctx, claimed, "發文失敗:"+err.Error()) } now := clock.NowUnixNano() claimed.Status = entity.StatusPublished claimed.MediaID = result.MediaID claimed.Permalink = result.Permalink claimed.PublishedAt = now claimed.UpdateAt = now claimed.ErrorMessage = "" if _, err := u.repo.UpdateIfStatus(ctx, claimed, []string{entity.StatusPublishing}); err != nil { return err } _ = u.publishAnalytics.ScheduleCheckpoints(ctx, padomain.ScheduleCheckpointsRequest{ TenantID: claimed.TenantID, OwnerUID: claimed.OwnerUID, AccountID: claimed.AccountID, MediaID: result.MediaID, Permalink: result.Permalink, PublishedAt: now, }) return nil } func (u *publishQueueUseCase) markFailed(ctx context.Context, item *entity.QueueItem, message string) error { item.Status = entity.StatusFailed item.ErrorMessage = message item.UpdateAt = clock.NowUnixNano() _, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusPublishing, entity.StatusScheduled}) return err } func (u *publishQueueUseCase) ListPublishHealth(ctx context.Context, tenantID, ownerUID, accountID string, page, pageSize int) (*domusecase.PublishHealthResult, error) { if err := requireActor(tenantID, ownerUID); err != nil { return nil, err } if page <= 0 { page = 1 } if pageSize <= 0 { pageSize = 10 } if pageSize > 30 { pageSize = 30 } pending, _ := u.repo.CountByStatus(ctx, tenantID, ownerUID, accountID, entity.StatusScheduled) failed, _ := u.repo.CountByStatus(ctx, tenantID, ownerUID, accountID, entity.StatusFailed) publishedList, err := u.repo.List(ctx, domrepo.ListFilter{ TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID, Status: entity.StatusPublished, }, page, pageSize) if err != nil { return nil, err } cutoff7d := clock.NowUnixNano() - int64(7*24*time.Hour) var published7d int64 var totalLikes7d, totalReplies7d int items := make([]domusecase.PublishHealthItem, 0, len(publishedList.Items)) for _, row := range publishedList.Items { if row.PublishedAt >= cutoff7d { published7d++ } checkpoints, err := u.publishAnalytics.ListByMedia(ctx, tenantID, ownerUID, row.MediaID) if err != nil { checkpoints = nil } healthCPs := make([]domusecase.PublishHealthCheckpoint, 0, len(checkpoints)) var latestLike, latestReply, latestRepost, latestQuote, latestViews, latestShares int var latestChecked int64 for _, cp := range checkpoints { healthCPs = append(healthCPs, domusecase.PublishHealthCheckpoint{ Checkpoint: cp.Checkpoint, LikeCount: cp.LikeCount, ReplyCount: cp.ReplyCount, RepostCount: cp.RepostCount, QuoteCount: cp.QuoteCount, Views: cp.ViewCount, CheckedAt: cp.CheckedAt, }) if cp.CheckedAt >= latestChecked { latestChecked = cp.CheckedAt latestLike = cp.LikeCount latestReply = cp.ReplyCount latestRepost = cp.RepostCount latestQuote = cp.QuoteCount latestViews = cp.ViewCount latestShares = cp.ShareCount } } if row.PublishedAt >= cutoff7d { totalLikes7d += latestLike totalReplies7d += latestReply } items = append(items, domusecase.PublishHealthItem{ MediaID: row.MediaID, Permalink: row.Permalink, Text: row.Text, PublishedAt: row.PublishedAt, LikeCount: latestLike, ReplyCount: latestReply, RepostCount: latestRepost, QuoteCount: latestQuote, Views: latestViews, Shares: latestShares, Checkpoints: healthCPs, }) } totalPages := 0 if pageSize > 0 { totalPages = int((publishedList.Total + int64(pageSize) - 1) / int64(pageSize)) } return &domusecase.PublishHealthResult{ Summary: domusecase.PublishHealthSummary{ PendingScheduled: pending, FailedCount: failed, Published7d: published7d, TotalLikes7d: totalLikes7d, TotalReplies7d: totalReplies7d, }, Items: items, Total: publishedList.Total, Page: page, PageSize: pageSize, TotalPages: totalPages, }, nil } func requireActor(tenantID, ownerUID string) error { if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" { return app.For(code.Auth).AuthUnauthorized("missing actor") } return nil } func toSummary(item entity.QueueItem) domusecase.QueueItemSummary { return domusecase.QueueItemSummary{ ID: item.ID, AccountID: item.AccountID, Text: item.Text, ScheduledAt: item.ScheduledAt, Status: item.Status, MediaID: item.MediaID, Permalink: item.Permalink, PublishedAt: item.PublishedAt, ErrorMessage: item.ErrorMessage, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt, } }